refactor: Redo initialization logic

Initialization logic has been completely overhauled. The previous
implementation was based on an approach that prioritized keeping the
composition root in the Program class. However, I wasn't happy with
this. CliFx inevitably wants to be the effective entry point to the
application. This means that the Program class should be as dumb as
possible.

The motivation for all this rework is the Recyclarr GUI. I need to be
able to share more initialization code between the projects.

Along with the initialization logic changes, I unintentionally
interleaved in another, completely unrelated refactoring. The IAppPaths
class now uses `IFileInfo` / `IDirectoryInfo` instead of `string` for
everything. This greatly simplified the implementation of that interface
and reduced dependencies and complexity across the code base. However,
those changes were vast and required rewriting/fixing a lot of unit
tests.
gui
Robert Dailey 2 years ago
parent acd452f300
commit a8cce8164e

@ -57,7 +57,9 @@ public class FileSystemExtensionsTest
var fs = NewMockFileSystem(files, dirs, @"C:\root\path");
fs.MergeDirectory("path1", "path2");
fs.MergeDirectory(
fs.DirectoryInfo.FromDirectoryName("path1"),
fs.DirectoryInfo.FromDirectoryName("path2"));
fs.AllDirectories.Select(MockUnixSupport.Path).Should()
.NotContain(x => x.Contains("path1") || x.Contains("empty"));
@ -77,7 +79,9 @@ public class FileSystemExtensionsTest
var fs = NewMockFileSystem(files, @"C:\root\path");
var act = () => fs.MergeDirectory("path1", "path2");
var act = () => fs.MergeDirectory(
fs.DirectoryInfo.FromDirectoryName("path1"),
fs.DirectoryInfo.FromDirectoryName("path2"));
act.Should().Throw<IOException>();
}
@ -97,7 +101,9 @@ public class FileSystemExtensionsTest
var fs = NewMockFileSystem(files, dirs, @"C:\root\path");
var act = () => fs.MergeDirectory("path1", "path2");
var act = () => fs.MergeDirectory(
fs.DirectoryInfo.FromDirectoryName("path1"),
fs.DirectoryInfo.FromDirectoryName("path2"));
act.Should().Throw<IOException>();
}

@ -6,14 +6,12 @@ namespace Common.Extensions;
public static class FileSystemExtensions
{
public static void MergeDirectory(this IFileSystem fs, string targetDir, string destDir, IConsole? console = null)
public static void MergeDirectory(this IFileSystem fs, IDirectoryInfo targetDir, IDirectoryInfo destDir,
IConsole? console = null)
{
targetDir = fs.Path.GetFullPath(targetDir);
destDir = fs.Path.GetFullPath(destDir);
var directories = fs.DirectoryInfo.FromDirectoryName(targetDir)
var directories = targetDir
.EnumerateDirectories("*", SearchOption.AllDirectories)
.Append(fs.DirectoryInfo.FromDirectoryName(targetDir))
.Append(targetDir)
.OrderByDescending(x => x.FullName.Count(y => y is '/' or '\\'));
foreach (var dir in directories)
@ -23,7 +21,7 @@ public static class FileSystemExtensions
// Is it a symbolic link?
if ((dir.Attributes & FileAttributes.ReparsePoint) != 0)
{
var newPath = RelocatePath(dir.FullName, targetDir, destDir);
var newPath = RelocatePath(dir.FullName, targetDir.FullName, destDir.FullName);
fs.Directory.CreateDirectory(fs.Path.GetDirectoryName(newPath));
console?.Output.WriteLine($" - Symlink: {dir.FullName} :: TO :: {newPath}");
dir.MoveTo(newPath);
@ -33,7 +31,7 @@ public static class FileSystemExtensions
// For real directories, move all the files inside
foreach (var file in dir.EnumerateFiles())
{
var newPath = RelocatePath(file.FullName, targetDir, destDir);
var newPath = RelocatePath(file.FullName, targetDir.FullName, destDir.FullName);
fs.Directory.CreateDirectory(fs.Path.GetDirectoryName(newPath));
console?.Output.WriteLine($" - Moving: {file.FullName} :: TO :: {newPath}");
file.MoveTo(newPath);

@ -25,7 +25,6 @@
<PackageReference Update="morelinq" Version="3.*" />
<PackageReference Update="Newtonsoft.Json" Version="13.*" />
<PackageReference Update="Newtonsoft.Json.Schema" Version="3.*" />
<PackageReference Update="NSubstitute" Version="4.*" />
<PackageReference Update="NSubstitute.Analyzers.CSharp" Version="1.*" />
<PackageReference Update="NUnit" Version="3.*" />
<PackageReference Update="NUnit.Analyzers" Version="3.*" />
@ -41,6 +40,15 @@
<PackageReference Update="System.Reactive" Version="5.*" />
<PackageReference Update="TestableIO.System.IO.Abstractions.Extensions" Version="1.*" />
<PackageReference Update="YamlDotNet" Version="11.*" />
<!--
Cannot upgrade until these are resolved:
- https://github.com/nsubstitute/NSubstitute/pull/683
- https://github.com/nsubstitute/NSubstitute/issues/685
- https://github.com/MRCollective/AutofacContrib.NSubstitute/issues/65
-->
<PackageReference Update="NSubstitute" Version="4.2.2" />
</ItemGroup>
<ItemGroup Condition="$(ProjectName.EndsWith('.Tests'))">

@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Recyclarr\Recyclarr.csproj" />
</ItemGroup>
</Project>

@ -0,0 +1,12 @@
using System.IO.Abstractions;
using System.IO.Abstractions.Extensions;
namespace Recyclarr.TestLibrary;
public sealed class TestAppPaths : AppPaths
{
public TestAppPaths(IFileSystem fs)
: base(fs.CurrentDirectory().SubDirectory("recyclarr"))
{
}
}

@ -0,0 +1,51 @@
using System.IO.Abstractions.TestingHelpers;
using AutoFixture.NUnit3;
using CliFx.Attributes;
using CliFx.Infrastructure;
using FluentAssertions;
using NUnit.Framework;
using Recyclarr.Command;
using Recyclarr.TestLibrary;
using TestLibrary.AutoFixture;
namespace Recyclarr.Tests.Command;
[Command]
public class StubBaseCommand : BaseCommand
{
public override string? AppDataDirectory { get; set; }
public StubBaseCommand(ICompositionRoot compositionRoot)
{
CompositionRoot = compositionRoot;
}
public override Task Process(IServiceLocatorProxy container)
{
return Task.CompletedTask;
}
}
[TestFixture]
// Cannot be parallelized due to static CompositionRoot property
public class BaseCommandTest
{
[Test, AutoMockData]
public async Task All_directories_are_created(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen(Matching.ImplementedInterfaces)] TestAppPaths paths,
IConsole console,
StubBaseCommand sut)
{
await sut.ExecuteAsync(console);
var expectedDirs = new[]
{
paths.LogDirectory.FullName,
paths.RepoDirectory.FullName,
paths.CacheDirectory.FullName
};
expectedDirs.Should().IntersectWith(fs.AllDirectories);
}
}

@ -7,45 +7,50 @@ using FluentAssertions;
using NSubstitute;
using NUnit.Framework;
using Recyclarr.Command;
using Recyclarr.TestLibrary;
using TestLibrary.AutoFixture;
using TrashLib;
// ReSharper disable MethodHasAsyncOverload
namespace Recyclarr.Tests.Command;
[TestFixture]
[Parallelizable(ParallelScope.All)]
// Cannot be parallelized due to static CompositionRoot property
public class CreateConfigCommandTest
{
[Test, AutoMockData]
public async Task Config_file_created_when_using_default_path(
[Frozen] IAppPaths paths,
[Frozen(Matching.ImplementedInterfaces)] TestAppPaths paths,
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
CreateConfigCommand cmd)
IServiceLocatorProxy container,
ICompositionRoot compositionRoot,
CreateConfigCommand sut)
{
const string ymlPath = "path/recyclarr.yml";
paths.ConfigPath.Returns(ymlPath);
await cmd.ExecuteAsync(Substitute.For<IConsole>());
BaseCommand.CompositionRoot = compositionRoot;
var file = fs.GetFile(ymlPath);
await sut.Process(container);
var file = fs.GetFile(paths.ConfigPath.FullName);
file.Should().NotBeNull();
file.Contents.Should().NotBeEmpty();
}
[Test, AutoMockData]
public async Task Config_file_created_when_using_user_specified_path(
[Frozen] IAppPaths paths,
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
CreateConfigCommand cmd)
[Frozen(Matching.ImplementedInterfaces)] TestAppPaths paths,
ICompositionRoot compositionRoot,
CreateConfigCommand sut)
{
BaseCommand.CompositionRoot = compositionRoot;
var ymlPath = fs.CurrentDirectory()
.SubDirectory("user")
.SubDirectory("specified")
.File("file.yml").FullName;
cmd.Path = ymlPath;
await cmd.ExecuteAsync(Substitute.For<IConsole>());
sut.AppDataDirectory = ymlPath;
await sut.ExecuteAsync(Substitute.For<IConsole>());
var file = fs.GetFile(ymlPath);
file.Should().NotBeNull();

@ -1,61 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using Autofac;
using FluentAssertions;
using NUnit.Framework;
using Recyclarr.Command;
using Recyclarr.Command.Helpers;
namespace Recyclarr.Tests.Command.Helpers;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CliTypeActivatorTest
{
// Warning CA1812 : an internal class that is apparently never instantiated.
[SuppressMessage("Performance", "CA1812", Justification = "Registered to and created by Autofac")]
private class NonServiceCommandType
{
}
// Warning CA1812 : an internal class that is apparently never instantiated.
[SuppressMessage("Performance", "CA1812", Justification = "Registered to and created by Autofac")]
private class StubCommand : IServiceCommand
{
public bool Preview => false;
public bool Debug => false;
public ICollection<string> Config => new List<string>();
public string CacheStoragePath => "";
public string Name => "";
}
[Test]
public void Resolve_NonServiceCommandType_NoActiveCommandSet()
{
var builder = new ContainerBuilder();
builder.RegisterType<NonServiceCommandType>();
var container = CompositionRoot.Setup(builder);
var createdType = CliTypeActivator.ResolveType(container, typeof(NonServiceCommandType));
Action act = () => _ = container.Resolve<IActiveServiceCommandProvider>().ActiveCommand;
createdType.Should().BeOfType<NonServiceCommandType>();
act.Should()
.Throw<InvalidOperationException>()
.WithMessage("The active command has not yet been determined");
}
[Test]
public void Resolve_ServiceCommandType_ActiveCommandSet()
{
var builder = new ContainerBuilder();
builder.RegisterType<StubCommand>();
var container = CompositionRoot.Setup(builder);
var createdType = CliTypeActivator.ResolveType(container, typeof(StubCommand));
var activeCommand = container.Resolve<IActiveServiceCommandProvider>().ActiveCommand;
activeCommand.Should().BeSameAs(createdType);
activeCommand.Should().BeOfType<StubCommand>();
}
}

@ -7,9 +7,7 @@ using FluentAssertions;
using NSubstitute;
using NUnit.Framework;
using Recyclarr.Command.Initialization;
using TestLibrary;
using TestLibrary.AutoFixture;
using TrashLib;
namespace Recyclarr.Tests.Command.Initialization;
@ -21,55 +19,71 @@ public class DefaultAppDataSetupTest
public void Initialize_using_default_path(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen] IEnvironment env,
[Frozen] IAppPaths paths,
DefaultAppDataSetup sut)
{
env.GetEnvironmentVariable(default!).ReturnsForAnyArgs((string?) null);
paths.DefaultAppDataDirectoryName.Returns("app_data");
env.GetFolderPath(Arg.Any<Environment.SpecialFolder>(), Arg.Any<Environment.SpecialFolderOption>())
.Returns(FileUtils.NormalizePath("base/path"));
var basePath = fs.CurrentDirectory()
.SubDirectory("base")
.SubDirectory("path");
sut.SetupDefaultPath(null, false);
env.GetFolderPath(default, default).ReturnsForAnyArgs(basePath.FullName);
paths.Received().SetAppDataPath(FileUtils.NormalizePath("base/path/app_data"));
var paths = sut.CreateAppPaths();
paths.AppDataDirectory.FullName.Should().Be(basePath.SubDirectory("recyclarr").FullName);
}
[Test, AutoMockData]
public void Initialize_using_path_override(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen] IAppPaths paths,
DefaultAppDataSetup sut)
{
var overridePath = FileUtils.NormalizePath("/override/path");
sut.SetupDefaultPath(overridePath, false);
var overridePath = fs.CurrentDirectory()
.SubDirectory("override")
.SubDirectory("path");
var paths = sut.CreateAppPaths(overridePath.FullName);
paths.Received().SetAppDataPath(overridePath);
fs.AllDirectories.Should().Contain(overridePath);
paths.AppDataDirectory.FullName.Should().Be(overridePath.FullName);
}
[Test, AutoMockData]
public void Force_creation_uses_correct_behavior_when_disabled(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen] IEnvironment env,
DefaultAppDataSetup sut)
{
var overridePath = fs.CurrentDirectory()
.SubDirectory("override")
.SubDirectory("path");
env.GetEnvironmentVariable(default!).ReturnsForAnyArgs((string?) null);
env.GetFolderPath(default).ReturnsForAnyArgs(overridePath.FullName);
sut.SetupDefaultPath(null, false);
sut.CreateAppPaths(null, false);
env.Received().GetFolderPath(Environment.SpecialFolder.ApplicationData, Environment.SpecialFolderOption.None);
fs.AllDirectories.Should().NotContain(overridePath.FullName);
}
[Test, AutoMockData]
public void Force_creation_uses_correct_behavior_when_enabled(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen] IEnvironment env,
DefaultAppDataSetup sut)
{
var overridePath = fs.CurrentDirectory()
.SubDirectory("override")
.SubDirectory("path");
env.GetEnvironmentVariable(default!).ReturnsForAnyArgs((string?) null);
env.GetFolderPath(default).ReturnsForAnyArgs(overridePath.FullName);
sut.SetupDefaultPath(null, true);
sut.CreateAppPaths();
env.Received().GetFolderPath(Environment.SpecialFolder.ApplicationData, Environment.SpecialFolderOption.Create);
fs.AllDirectories.Should().NotContain(overridePath.FullName);
}
[Test, AutoMockData]
@ -85,7 +99,7 @@ public class DefaultAppDataSetupTest
env.GetEnvironmentVariable(default!).ReturnsForAnyArgs(expectedPath);
sut.SetupDefaultPath(null, true);
sut.CreateAppPaths();
env.Received().GetEnvironmentVariable("RECYCLARR_APP_DATA");
fs.AllDirectories.Should().Contain(expectedPath);
@ -102,7 +116,7 @@ public class DefaultAppDataSetupTest
.SubDirectory("var")
.SubDirectory("path").FullName;
sut.SetupDefaultPath(expectedPath, true);
sut.CreateAppPaths(expectedPath);
env.DidNotReceiveWithAnyArgs().GetEnvironmentVariable(default!);
fs.AllDirectories.Should().Contain(expectedPath);

@ -1,37 +0,0 @@
using System.IO.Abstractions.TestingHelpers;
using AutoFixture.NUnit3;
using Common;
using FluentAssertions;
using NUnit.Framework;
using Recyclarr.Command;
using Recyclarr.Command.Initialization.Init;
using TestLibrary.AutoFixture;
using TrashLib;
namespace Recyclarr.Tests.Command.Initialization.Init;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class InitializeAppDataPathTest
{
[Test, AutoMockData]
public void All_directories_are_created(
[Frozen] IEnvironment env,
[Frozen] IAppPaths paths,
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
SonarrCommand cmd,
InitializeAppDataPath sut)
{
sut.Initialize(cmd);
var expectedDirs = new[]
{
paths.LogDirectory,
paths.RepoDirectory,
paths.CacheDirectory
};
fs.AllDirectories.Select(x => fs.Path.GetFileName(x))
.Should().IntersectWith(expectedDirs);
}
}

@ -1,49 +0,0 @@
using FluentAssertions;
using NSubstitute;
using NUnit.Framework;
using Recyclarr.Command;
using Recyclarr.Command.Initialization;
using Recyclarr.Command.Initialization.Cleanup;
using Recyclarr.Command.Initialization.Init;
using TestLibrary.AutoFixture;
namespace Recyclarr.Tests.Command.Initialization;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ServiceInitializationAndCleanupTest
{
[Test, AutoMockData]
public async Task Cleanup_happens_when_exception_occurs_in_action(
ServiceCommand cmd,
IServiceCleaner cleaner)
{
var sut = new ServiceInitializationAndCleanup(
Enumerable.Empty<IServiceInitializer>().OrderBy(_ => 1),
new[] {cleaner}.OrderBy(_ => 1));
var act = () => sut.Execute(cmd, () => throw new NullReferenceException());
await act.Should().ThrowAsync<NullReferenceException>();
cleaner.Received().Cleanup();
}
[Test, AutoMockData]
public async Task Cleanup_happens_when_exception_occurs_in_init(
ServiceCommand cmd,
IServiceInitializer init,
IServiceCleaner cleaner)
{
var sut = new ServiceInitializationAndCleanup(
new[] {init}.OrderBy(_ => 1),
new[] {cleaner}.OrderBy(_ => 1));
init.WhenForAnyArgs(x => x.Initialize(default!))
.Do(_ => throw new NullReferenceException());
var act = () => sut.Execute(cmd, () => Task.CompletedTask);
await act.Should().ThrowAsync<NullReferenceException>();
cleaner.Received().Cleanup();
}
}

@ -1,45 +1,45 @@
using AutoFixture.NUnit3;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using FluentAssertions;
using NSubstitute;
using NUnit.Framework;
using Recyclarr.Command;
using Recyclarr.Command.Services;
using TestLibrary.AutoFixture;
using TrashLib.Sonarr;
namespace Recyclarr.Tests.Command.Services;
namespace Recyclarr.Tests.Command;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrServiceTest
// Cannot be parallelized due to static CompositionRoot property
public class SonarrCommandTest
{
[Test, AutoMockData]
public async Task List_terms_without_value_fails(
[Frozen] ISonarrCommand cmd,
SonarrService sut)
IConsole console,
SonarrCommand sut)
{
cmd.ListReleaseProfiles.Returns(false);
sut.ListReleaseProfiles = false;
// When `--list-terms` is specified on the command line without a value, it gets a `null` value assigned.
cmd.ListTerms.Returns((string?) null);
sut.ListTerms = null;
var act = () => sut.Execute(cmd);
var act = async () => await sut.ExecuteAsync(console);
await act.Should().ThrowAsync<CommandException>();
}
[Test, AutoMockData]
public async Task List_terms_with_empty_value_fails(
[Frozen] ISonarrCommand cmd,
SonarrService sut)
IConsole console,
SonarrCommand sut)
{
cmd.ListReleaseProfiles.Returns(false);
sut.ListReleaseProfiles = false;
// If the user specifies a blank string as the value, it should still fail.
cmd.ListTerms.Returns("");
sut.ListTerms = "";
var act = () => sut.Execute(cmd);
var act = async () => await sut.ExecuteAsync(console);
await act.Should().ThrowAsync<CommandException>();
}
@ -47,14 +47,16 @@ public class SonarrServiceTest
[Test, AutoMockData]
public async Task List_terms_uses_specified_trash_id(
[Frozen] IReleaseProfileLister lister,
[Frozen] ISonarrCommand cmd,
SonarrService sut)
IConsole console,
ICompositionRoot compositionRoot,
SonarrCommand sut)
{
cmd.ListReleaseProfiles.Returns(false);
BaseCommand.CompositionRoot = compositionRoot;
sut.ListReleaseProfiles = false;
cmd.ListTerms.Returns("some_id");
sut.ListTerms = "some_id";
await sut.Execute(cmd);
await sut.ExecuteAsync(console);
lister.Received().ListTerms("some_id");
}
@ -62,13 +64,16 @@ public class SonarrServiceTest
[Test, AutoMockData]
public async Task List_release_profiles_is_invoked(
[Frozen] IReleaseProfileLister lister,
[Frozen] ISonarrCommand cmd,
SonarrService sut)
IConsole console,
ICompositionRoot compositionRoot,
SonarrCommand sut)
{
cmd.ListReleaseProfiles.Returns(true);
cmd.ListTerms.Returns((string?) null);
BaseCommand.CompositionRoot = compositionRoot;
await sut.Execute(cmd);
sut.ListReleaseProfiles = true;
sut.ListTerms = null;
await sut.ExecuteAsync(console);
lister.Received().ListReleaseProfiles();
}

@ -2,7 +2,9 @@ using System.Collections;
using System.Diagnostics.CodeAnalysis;
using Autofac;
using Autofac.Core;
using CliFx.Infrastructure;
using FluentAssertions;
using NSubstitute;
using NUnit.Framework;
using VersionControl;
@ -33,7 +35,7 @@ public class CompositionRootTest
{
var act = () =>
{
using var container = CompositionRoot.Setup();
using var container = new CompositionRoot().Setup("", Substitute.For<IConsole>(), default).Container;
service.Instantiate(container);
};
@ -47,11 +49,11 @@ public class CompositionRootTest
)]
private sealed class ConcreteTypeEnumerator : IEnumerable
{
private readonly IContainer _container;
private readonly ILifetimeScope _container;
public ConcreteTypeEnumerator()
{
_container = CompositionRoot.Setup();
_container = new CompositionRoot().Setup("", Substitute.For<IConsole>(), default).Container;
}
public IEnumerator GetEnumerator()
@ -70,7 +72,7 @@ public class CompositionRootTest
[TestCaseSource(typeof(ConcreteTypeEnumerator))]
public void Service_should_be_instantiable(Type service)
{
using var container = CompositionRoot.Setup();
using var container = new CompositionRoot().Setup("", Substitute.For<IConsole>(), default).Container;
container.Invoking(c => c.Resolve(service))
.Should().NotThrow()
.And.NotBeNull();

@ -1,11 +1,13 @@
using System.IO.Abstractions;
using System.IO.Abstractions.Extensions;
using System.IO.Abstractions.TestingHelpers;
using AutoFixture.NUnit3;
using Common;
using FluentAssertions;
using NSubstitute;
using NUnit.Framework;
using Recyclarr.TestLibrary;
using TestLibrary.AutoFixture;
using TrashLib;
namespace Recyclarr.Tests;
@ -17,56 +19,48 @@ public class ConfigurationFinderTest
public void Return_path_next_to_executable_if_present(
[Frozen] IAppContext appContext,
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen] IAppPaths paths,
ConfigurationFinder sut)
{
var basePath = fs.Path.Combine("base", "path");
var baseYaml = fs.Path.Combine(basePath, "recyclarr.yml");
var basePath = fs.CurrentDirectory().SubDirectory("base").SubDirectory("path");
var baseYaml = basePath.File("recyclarr.yml");
paths.DefaultConfigFilename.Returns("recyclarr.yml");
appContext.BaseDirectory.Returns(basePath);
fs.AddFile(baseYaml, new MockFileData(""));
appContext.BaseDirectory.Returns(basePath.FullName);
fs.AddFile(baseYaml.FullName, new MockFileData(""));
var path = sut.FindConfigPath();
path.Should().EndWith(baseYaml);
path.FullName.Should().Be(baseYaml.FullName);
}
[Test, AutoMockData]
public void Return_app_data_dir_location_if_base_directory_location_not_present(
[Frozen] IAppContext appContext,
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen] IAppPaths paths,
[Frozen(Matching.ImplementedInterfaces)] TestAppPaths paths,
ConfigurationFinder sut)
{
var appYaml = fs.Path.Combine("app", "data", "recyclarr.yml");
paths.ConfigPath.Returns(appYaml);
var path = sut.FindConfigPath();
path.Should().EndWith(appYaml);
path.FullName.Should().Be(paths.ConfigPath.FullName);
}
[Test, AutoMockData]
public void Return_base_directory_location_if_both_files_are_present(
[Frozen] IAppContext appContext,
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen] IAppPaths paths,
ConfigurationFinder sut)
{
var appPath = fs.Path.Combine("app", "data");
var basePath = fs.Path.Combine("base", "path");
var baseYaml = fs.Path.Combine(basePath, "recyclarr.yml");
var appYaml = fs.Path.Combine(appPath, "recyclarr.yml");
var appPath = fs.CurrentDirectory().SubDirectory("app").SubDirectory("data");
var basePath = fs.CurrentDirectory().SubDirectory("base").SubDirectory("path");
var baseYaml = basePath.File("recyclarr.yml");
var appYaml = appPath.File("recyclarr.yml");
paths.DefaultConfigFilename.Returns("recyclarr.yml");
appContext.BaseDirectory.Returns(basePath);
fs.AddFile(baseYaml, new MockFileData(""));
fs.AddFile(appYaml, new MockFileData(""));
appContext.BaseDirectory.Returns(basePath.FullName);
fs.AddFile(baseYaml.FullName, new MockFileData(""));
fs.AddFile(appYaml.FullName, new MockFileData(""));
var path = sut.FindConfigPath();
path.Should().EndWith(baseYaml);
path.FullName.Should().Be(baseYaml.FullName);
}
}

@ -1,12 +1,12 @@
using System.IO.Abstractions;
using System.IO.Abstractions.TestingHelpers;
using AutoFixture.NUnit3;
using FluentAssertions;
using MoreLinq.Extensions;
using NSubstitute;
using NUnit.Framework;
using Recyclarr.Logging;
using Recyclarr.TestLibrary;
using TestLibrary.AutoFixture;
using TrashLib;
namespace Recyclarr.Tests;
@ -16,26 +16,24 @@ public class LogJanitorTest
{
[Test, AutoMockData]
public void Keep_correct_number_of_newest_log_files(
[Frozen] IAppPaths paths,
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen(Matching.ImplementedInterfaces)] TestAppPaths paths,
LogJanitor janitor)
{
const string logDir = "C:\\logs";
paths.LogDirectory.Returns(logDir);
var testFiles = new[]
{
$"{logDir}\\trash_2021-05-15_19-00-00",
$"{logDir}\\trash_2021-05-15_20-00-00",
$"{logDir}\\trash_2021-05-15_21-00-00",
$"{logDir}\\trash_2021-05-15_22-00-00"
paths.LogDirectory.File("trash_2021-05-15_19-00-00.log"),
paths.LogDirectory.File("trash_2021-05-15_20-00-00.log"),
paths.LogDirectory.File("trash_2021-05-15_21-00-00.log"),
paths.LogDirectory.File("trash_2021-05-15_22-00-00.log")
};
testFiles.ForEach(x => fs.AddFile(x, new MockFileData("")));
testFiles.ForEach(x => fs.AddFile(x.FullName, new MockFileData("")));
janitor.DeleteOldestLogFiles(2);
fs.FileExists(testFiles[2]).Should().BeTrue();
fs.FileExists(testFiles[3]).Should().BeTrue();
fs.AllFiles.Should().BeEquivalentTo(
testFiles[2].FullName,
testFiles[3].FullName);
}
}

@ -1,29 +0,0 @@
using System.IO.Abstractions;
using AutoFixture.NUnit3;
using NSubstitute;
using NUnit.Framework;
using Recyclarr.Logging;
using Serilog.Events;
using TestLibrary.AutoFixture;
using TrashLib;
namespace Recyclarr.Tests.Logging;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class DelayedFileSinkTest
{
[Test, AutoMockData]
public void Should_not_open_file_if_app_data_invalid(
[Frozen] IAppPaths paths,
[Frozen] IFileSystem fs,
LogEvent logEvent,
DelayedFileSink sut)
{
paths.IsAppDataPathValid.Returns(false);
sut.Emit(logEvent);
fs.File.DidNotReceiveWithAnyArgs().OpenWrite(default!);
}
}

@ -1,4 +1,3 @@
using Autofac;
using CliFx.Infrastructure;
using FluentAssertions;
using NSubstitute;
@ -15,7 +14,7 @@ public class MigrationExecutorTest
[Test]
public void Migration_steps_are_in_expected_order()
{
var container = CompositionRoot.Setup();
var container = new CompositionRoot().Setup("", Substitute.For<IConsole>(), default);
var steps = container.Resolve<IEnumerable<IMigrationStep>>();
var orderedSteps = steps.OrderBy(x => x.Order).Select(x => x.GetType()).ToList();
orderedSteps.Should().BeEquivalentTo(

@ -1,8 +1,10 @@
using System.IO.Abstractions;
using System.IO.Abstractions.TestingHelpers;
using AutoFixture.NUnit3;
using FluentAssertions;
using NUnit.Framework;
using Recyclarr.Migration.Steps;
using Recyclarr.TestLibrary;
using TestLibrary;
using TestLibrary.AutoFixture;
@ -14,17 +16,15 @@ public class MigrateTrashUpdaterAppDataDirTest
{
[Test, AutoMockData]
public void Migration_check_returns_true_if_trash_updater_dir_exists(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen(Matching.ImplementedInterfaces)] TestAppPaths paths,
MigrateTrashUpdaterAppDataDir sut)
{
fs.AddDirectory(fs.Path.Combine(paths.BasePath, "trash-updater"));
paths.AppDataDirectory.Parent.SubDirectory("trash-updater").Create();
sut.CheckIfNeeded().Should().BeTrue();
}
[Test, AutoMockData]
public void Migration_check_returns_false_if_trash_updater_dir_doesnt_exists(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
MigrateTrashUpdaterAppDataDir sut)
{
sut.CheckIfNeeded().Should().BeFalse();
@ -36,8 +36,8 @@ public class MigrateTrashUpdaterAppDataDirTest
[Frozen(Matching.ImplementedInterfaces)] TestAppPaths paths,
MigrateTrashUpdaterAppDataDir sut)
{
fs.AddFileNoData($"{paths.BasePath}/trash-updater/recyclarr.yml");
fs.AddFileNoData($"{paths.BasePath}/recyclarr/recyclarr.yml");
fs.AddFileNoData(sut.OldPath.File("recyclarr.yml"));
fs.AddFileNoData(sut.NewPath.File("recyclarr.yml"));
var act = () => sut.Execute(null);
@ -51,20 +51,23 @@ public class MigrateTrashUpdaterAppDataDirTest
MigrateTrashUpdaterAppDataDir sut)
{
// Add file instead of directory since the migration step only operates on files
fs.AddFileNoData($"{paths.BasePath}/trash-updater/settings.yml");
fs.AddFileNoData($"{paths.BasePath}/trash-updater/recyclarr.yml");
fs.AddFileNoData($"{paths.BasePath}/trash-updater/this-gets-ignored.yml");
fs.AddDirectory2($"{paths.BasePath}/trash-updater/repo");
fs.AddDirectory2($"{paths.BasePath}/trash-updater/cache");
fs.AddFileNoData($"{paths.BasePath}/trash-updater/cache/sonarr/test.txt");
var baseDir = sut.OldPath;
fs.AddFileNoData(baseDir.File("settings.yml"));
fs.AddFileNoData(baseDir.File("recyclarr.yml"));
fs.AddFileNoData(baseDir.File("this-gets-ignored.yml"));
fs.AddDirectory(baseDir.SubDirectory("repo"));
fs.AddDirectory(baseDir.SubDirectory("cache"));
fs.AddFileNoData(baseDir.File("cache/sonarr/test.txt"));
sut.Execute(null);
var expectedBase = sut.NewPath;
fs.AllDirectories.Should().NotContain(x => x.Contains("trash-updater"));
fs.AllFiles.Should().BeEquivalentTo(
FileUtils.NormalizePath($"/{paths.BasePath}/recyclarr/settings.yml"),
FileUtils.NormalizePath($"/{paths.BasePath}/recyclarr/recyclarr.yml"),
FileUtils.NormalizePath($"/{paths.BasePath}/recyclarr/cache/sonarr/test.txt"));
expectedBase.File("settings.yml").FullName,
expectedBase.File("recyclarr.yml").FullName,
expectedBase.SubDirectory("cache").SubDirectory("sonarr").File("test.txt").FullName);
}
[Test, AutoMockData]

@ -1,15 +0,0 @@
using System.IO.Abstractions;
namespace Recyclarr.Tests.Migration.Steps;
public class TestAppPaths : AppPaths
{
public string BasePath { get; }
public TestAppPaths(IFileSystem fs)
: base(fs)
{
BasePath = fs.Path.Combine("base", "path");
SetAppDataPath(fs.Path.Combine(BasePath, DefaultAppDataDirectoryName));
}
}

@ -4,6 +4,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Recyclarr.TestLibrary\Recyclarr.TestLibrary.csproj" />
<ProjectReference Include="..\TestLibrary\TestLibrary.csproj" />
<ProjectReference Include="..\Recyclarr\Recyclarr.csproj" />
</ItemGroup>

@ -29,6 +29,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VersionControl", "VersionCo
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VersionControl.Tests", "VersionControl.Tests\VersionControl.Tests.csproj", "{F81C7EA3-4ACA-4171-8A60-531F129A33C5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.TestLibrary", "Recyclarr.TestLibrary\Recyclarr.TestLibrary.csproj", "{77D1C695-94D4-46A9-8F12-41E54AF97750}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -79,6 +81,10 @@ Global
{F81C7EA3-4ACA-4171-8A60-531F129A33C5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F81C7EA3-4ACA-4171-8A60-531F129A33C5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F81C7EA3-4ACA-4171-8A60-531F129A33C5}.Release|Any CPU.Build.0 = Release|Any CPU
{77D1C695-94D4-46A9-8F12-41E54AF97750}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{77D1C695-94D4-46A9-8F12-41E54AF97750}.Debug|Any CPU.Build.0 = Debug|Any CPU
{77D1C695-94D4-46A9-8F12-41E54AF97750}.Release|Any CPU.ActiveCfg = Release|Any CPU
{77D1C695-94D4-46A9-8F12-41E54AF97750}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
EndGlobalSection

@ -1,12 +1,6 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeStyle/CodeCleanup/Profiles/=Corepoint_0020Health/@EntryIndexedValue">&lt;?xml version="1.0" encoding="utf-16"?&gt;&lt;Profile name="Corepoint Health"&gt;&lt;CSArrangeThisQualifier&gt;True&lt;/CSArrangeThisQualifier&gt;&lt;CSRemoveCodeRedundancies&gt;True&lt;/CSRemoveCodeRedundancies&gt;&lt;CSUseVar&gt;&lt;BehavourStyle&gt;DISABLED&lt;/BehavourStyle&gt;&lt;LocalVariableStyle&gt;IMPLICIT_WHEN_INITIALIZER_HAS_TYPE&lt;/LocalVariableStyle&gt;&lt;ForeachVariableStyle&gt;ALWAYS_EXPLICIT&lt;/ForeachVariableStyle&gt;&lt;/CSUseVar&gt;&lt;CSUpdateFileHeader&gt;False&lt;/CSUpdateFileHeader&gt;&lt;VBOptimizeImports&gt;False&lt;/VBOptimizeImports&gt;&lt;VBShortenReferences&gt;False&lt;/VBShortenReferences&gt;&lt;XMLReformatCode&gt;False&lt;/XMLReformatCode&gt;&lt;CSOptimizeUsings&gt;&lt;OptimizeUsings&gt;True&lt;/OptimizeUsings&gt;&lt;EmbraceInRegion&gt;False&lt;/EmbraceInRegion&gt;&lt;RegionName&gt;&lt;/RegionName&gt;&lt;/CSOptimizeUsings&gt;&lt;VBReformatCode&gt;False&lt;/VBReformatCode&gt;&lt;CSReformatCode&gt;True&lt;/CSReformatCode&gt;&lt;CSharpFormatDocComments&gt;True&lt;/CSharpFormatDocComments&gt;&lt;CSCodeStyleAttributes ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" RemoveRedundantParentheses="False" AddMissingParentheses="False" ArrangeBraces="True" ArrangeAttributes="True" ArrangeArgumentsStyle="False" ArrangeCodeBodyStyle="False" ArrangeVarStyle="True" /&gt;&lt;CSUseAutoProperty&gt;True&lt;/CSUseAutoProperty&gt;&lt;CSMakeFieldReadonly&gt;True&lt;/CSMakeFieldReadonly&gt;&lt;CSMakeAutoPropertyGetOnly&gt;True&lt;/CSMakeAutoPropertyGetOnly&gt;&lt;CSArrangeQualifiers&gt;True&lt;/CSArrangeQualifiers&gt;&lt;CSFixBuiltinTypeReferences&gt;True&lt;/CSFixBuiltinTypeReferences&gt;&lt;CSShortenReferences&gt;True&lt;/CSShortenReferences&gt;&lt;IDEA_SETTINGS&gt;&amp;lt;profile version="1.0"&amp;gt;&#xD;
&amp;lt;option name="myName" value="Corepoint Health" /&amp;gt;&#xD;
&amp;lt;/profile&amp;gt;&lt;/IDEA_SETTINGS&gt;&lt;/Profile&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/CodeCleanup/Profiles/=MyFullCleanup/@EntryIndexedValue"></s:String>
<s:Boolean x:Key="/Default/CodeStyle/CodeCleanup/Profiles/=MyFullCleanup/@EntryIndexRemoved">True</s:Boolean>
<s:String x:Key="/Default/CodeStyle/CodeCleanup/Profiles/=TrashUpdaterCleanup/@EntryIndexedValue">&lt;?xml version="1.0" encoding="utf-16"?&gt;&lt;Profile name="TrashUpdaterCleanup"&gt;&lt;CSCodeStyleAttributes ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeArgumentsStyle="True" ArrangeVarStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" /&gt;&lt;Xaml.RemoveRedundantNamespaceAlias&gt;True&lt;/Xaml.RemoveRedundantNamespaceAlias&gt;&lt;AspOptimizeRegisterDirectives&gt;True&lt;/AspOptimizeRegisterDirectives&gt;&lt;CppAddOverrideSpecifier&gt;True&lt;/CppAddOverrideSpecifier&gt;&lt;CppReplaceImportDirective&gt;True&lt;/CppReplaceImportDirective&gt;&lt;CppSortMemberInitializers&gt;True&lt;/CppSortMemberInitializers&gt;&lt;RemoveCodeRedundancies&gt;True&lt;/RemoveCodeRedundancies&gt;&lt;CSUseAutoProperty&gt;True&lt;/CSUseAutoProperty&gt;&lt;CSMakeFieldReadonly&gt;True&lt;/CSMakeFieldReadonly&gt;&lt;CSMakeAutoPropertyGetOnly&gt;True&lt;/CSMakeAutoPropertyGetOnly&gt;&lt;CSArrangeQualifiers&gt;True&lt;/CSArrangeQualifiers&gt;&lt;CSFixBuiltinTypeReferences&gt;True&lt;/CSFixBuiltinTypeReferences&gt;&lt;CSOptimizeUsings&gt;&lt;OptimizeUsings&gt;True&lt;/OptimizeUsings&gt;&lt;/CSOptimizeUsings&gt;&lt;CSReformatCode&gt;True&lt;/CSReformatCode&gt;&lt;CSharpFormatDocComments&gt;True&lt;/CSharpFormatDocComments&gt;&lt;IDEA_SETTINGS&gt;&amp;lt;profile version="1.0"&amp;gt;&#xD;
&amp;lt;option name="myName" value="TrashUpdaterCleanup" /&amp;gt;&#xD;
<s:String x:Key="/Default/CodeStyle/CodeCleanup/Profiles/=Recyclarr_0020Cleanup/@EntryIndexedValue">&lt;?xml version="1.0" encoding="utf-16"?&gt;&lt;Profile name="Recyclarr Cleanup"&gt;&lt;CSCodeStyleAttributes ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeArgumentsStyle="True" ArrangeVarStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" /&gt;&lt;Xaml.RemoveRedundantNamespaceAlias&gt;True&lt;/Xaml.RemoveRedundantNamespaceAlias&gt;&lt;AspOptimizeRegisterDirectives&gt;True&lt;/AspOptimizeRegisterDirectives&gt;&lt;CppAddOverrideSpecifier&gt;True&lt;/CppAddOverrideSpecifier&gt;&lt;CppReplaceImportDirective&gt;True&lt;/CppReplaceImportDirective&gt;&lt;CppSortMemberInitializers&gt;True&lt;/CppSortMemberInitializers&gt;&lt;RemoveCodeRedundancies&gt;True&lt;/RemoveCodeRedundancies&gt;&lt;CSUseAutoProperty&gt;True&lt;/CSUseAutoProperty&gt;&lt;CSMakeFieldReadonly&gt;True&lt;/CSMakeFieldReadonly&gt;&lt;CSMakeAutoPropertyGetOnly&gt;True&lt;/CSMakeAutoPropertyGetOnly&gt;&lt;CSArrangeQualifiers&gt;True&lt;/CSArrangeQualifiers&gt;&lt;CSFixBuiltinTypeReferences&gt;True&lt;/CSFixBuiltinTypeReferences&gt;&lt;CSOptimizeUsings&gt;&lt;OptimizeUsings&gt;True&lt;/OptimizeUsings&gt;&lt;/CSOptimizeUsings&gt;&lt;CSReformatCode&gt;True&lt;/CSReformatCode&gt;&lt;CSharpFormatDocComments&gt;True&lt;/CSharpFormatDocComments&gt;&lt;IDEA_SETTINGS&gt;&amp;lt;profile version="1.0"&amp;gt;&#xD;
&amp;lt;option name="myName" value="Recyclarr Cleanup" /&amp;gt;&#xD;
&amp;lt;inspection_tool class="ES6ShorthandObjectProperty" enabled="false" level="WARNING" enabled_by_default="false" /&amp;gt;&#xD;
&amp;lt;inspection_tool class="JSArrowFunctionBracesCanBeRemoved" enabled="false" level="WARNING" enabled_by_default="false" /&amp;gt;&#xD;
&amp;lt;inspection_tool class="JSPrimitiveTypeWrapperUsage" enabled="false" level="WARNING" enabled_by_default="false" /&amp;gt;&#xD;
@ -60,10 +54,14 @@
&amp;lt;Reformat&amp;gt;false&amp;lt;/Reformat&amp;gt;&#xD;
&amp;lt;/Language&amp;gt;&#xD;
&amp;lt;/profile&amp;gt;&lt;/RIDER_SETTINGS&gt;&lt;HtmlReformatCode&gt;True&lt;/HtmlReformatCode&gt;&lt;FormatAttributeQuoteDescriptor&gt;True&lt;/FormatAttributeQuoteDescriptor&gt;&lt;/Profile&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/CodeCleanup/SilentCleanupProfile/@EntryValue">TrashUpdaterCleanup</s:String>
<s:String x:Key="/Default/CodeStyle/CodeCleanup/Profiles/=TrashUpdaterCleanup/@EntryIndexedValue"></s:String>
<s:Boolean x:Key="/Default/CodeStyle/CodeCleanup/Profiles/=TrashUpdaterCleanup/@EntryIndexRemoved">True</s:Boolean>
<s:String x:Key="/Default/CodeStyle/CodeCleanup/SilentCleanupProfile/@EntryValue">Recyclarr Cleanup</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=customformat/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Persister/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=qualitydefinition/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=qualityprofile/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Radarr/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Sonarr/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Sonarr/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

@ -1,4 +1,3 @@
using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using TrashLib;
@ -6,27 +5,19 @@ namespace Recyclarr;
public class AppPaths : IAppPaths
{
private readonly IFileSystem _fs;
private string? _appDataPath;
public AppPaths(IFileSystem fs)
public AppPaths(IDirectoryInfo appDataPath)
{
_fs = fs;
AppDataDirectory = appDataPath;
}
public string DefaultConfigFilename => "recyclarr.yml";
public string DefaultAppDataDirectoryName => "recyclarr";
public bool IsAppDataPathValid => _appDataPath is not null;
public void SetAppDataPath(string path) => _appDataPath = path;
public static string DefaultConfigFilename => "recyclarr.yml";
public static string DefaultAppDataDirectoryName => "recyclarr";
[SuppressMessage("Design", "CA1024:Use properties where appropriate")]
public string GetAppDataPath()
=> _appDataPath ?? throw new DirectoryNotFoundException("Application data directory not set!");
public IDirectoryInfo AppDataDirectory { get; }
public string ConfigPath => _fs.Path.Combine(GetAppDataPath(), DefaultConfigFilename);
public string SettingsPath => _fs.Path.Combine(GetAppDataPath(), "settings.yml");
public string LogDirectory => _fs.Path.Combine(GetAppDataPath(), "logs");
public string RepoDirectory => _fs.Path.Combine(GetAppDataPath(), "repo");
public string CacheDirectory => _fs.Path.Combine(GetAppDataPath(), "cache");
public IFileInfo ConfigPath => AppDataDirectory.File(DefaultConfigFilename);
public IFileInfo SettingsPath => AppDataDirectory.File("settings.yml");
public IDirectoryInfo LogDirectory => AppDataDirectory.SubDirectory("logs");
public IDirectoryInfo RepoDirectory => AppDataDirectory.SubDirectory("repo");
public IDirectoryInfo CacheDirectory => AppDataDirectory.SubDirectory("cache");
}

@ -0,0 +1,54 @@
using CliFx;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using JetBrains.Annotations;
using Recyclarr.Logging;
using Serilog;
using Serilog.Events;
using TrashLib;
namespace Recyclarr.Command;
public abstract class BaseCommand : ICommand
{
// Not explicitly defined as a Command here because for legacy reasons, different subcommands expose this in
// different ways to the user.
public abstract string? AppDataDirectory { get; set; }
[CommandOption("debug", 'd', Description =
"Display additional logs useful for development/debug purposes.")]
public bool Debug { get; [UsedImplicitly] set; } = false;
public static ICompositionRoot? CompositionRoot { get; set; }
public virtual async ValueTask ExecuteAsync(IConsole console)
{
// Must happen first because everything can use the logger.
var logLevel = Debug ? LogEventLevel.Debug : LogEventLevel.Information;
if (CompositionRoot is null)
{
throw new CommandException("CompositionRoot must not be null");
}
using var container = CompositionRoot.Setup(AppDataDirectory, console, logLevel);
var paths = container.Resolve<IAppPaths>();
var janitor = container.Resolve<ILogJanitor>();
var log = container.Resolve<ILogger>();
log.Debug("App Data Dir: {AppData}", paths.AppDataDirectory);
// Initialize other directories used throughout the application
paths.RepoDirectory.Create();
paths.CacheDirectory.Create();
paths.LogDirectory.Create();
await Process(container);
janitor.DeleteOldestLogFiles(20);
}
public abstract Task Process(IServiceLocatorProxy container);
}

@ -1,11 +1,8 @@
using System.IO.Abstractions;
using CliFx;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using Common;
using JetBrains.Annotations;
using Recyclarr.Command.Initialization;
using Serilog;
using TrashLib;
@ -13,44 +10,35 @@ namespace Recyclarr.Command;
[Command("create-config", Description = "Create a starter YAML configuration file")]
[UsedImplicitly]
public class CreateConfigCommand : ICommand
public class CreateConfigCommand : BaseCommand
{
private readonly IFileSystem _fs;
private readonly IAppPaths _paths;
private readonly IDefaultAppDataSetup _appDataSetup;
private readonly ILogger _log;
public CreateConfigCommand(ILogger logger, IFileSystem fs, IAppPaths paths, IDefaultAppDataSetup appDataSetup)
{
_log = logger;
_fs = fs;
_paths = paths;
_appDataSetup = appDataSetup;
}
[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 `recyclarr.yml` in the app data " +
"directory")]
public string? Path { get; set; }
public override string? AppDataDirectory { get; set; }
public ValueTask ExecuteAsync(IConsole console)
public override async Task Process(IServiceLocatorProxy container)
{
_appDataSetup.SetupDefaultPath(null, true);
var fs = container.Resolve<IFileSystem>();
var paths = container.Resolve<IAppPaths>();
var log = container.Resolve<ILogger>();
var reader = new ResourceDataReader(typeof(Program));
var ymlData = reader.ReadData("config-template.yml");
var path = _fs.Path.GetFullPath(Path ?? _paths.ConfigPath);
var configFile = AppDataDirectory is not null
? fs.FileInfo.FromFileName(AppDataDirectory)
: paths.ConfigPath;
if (_fs.File.Exists(path))
if (configFile.Exists)
{
throw new CommandException($"The file {path} already exists. Please choose another path or " +
throw new CommandException($"The file {configFile} already exists. Please choose another path or " +
"delete/move the existing file and run this command again.");
}
_fs.Directory.CreateDirectory(_fs.Path.GetDirectoryName(path));
_fs.File.WriteAllText(path, ymlData);
_log.Information("Created configuration at: {Path}", path);
return default;
fs.Directory.CreateDirectory(configFile.DirectoryName);
await using var stream = configFile.CreateText();
await stream.WriteAsync(ymlData);
log.Information("Created configuration at: {Path}", configFile);
}
}

@ -1,15 +1,20 @@
using System.IO.Abstractions;
using TrashLib;
using TrashLib.Cache;
namespace Recyclarr.Command.Helpers;
public class CacheStoragePath : ICacheStoragePath
{
private readonly IAppPaths _paths;
private readonly IActiveServiceCommandProvider _serviceCommandProvider;
public CacheStoragePath(IActiveServiceCommandProvider serviceCommandProvider)
public CacheStoragePath(IAppPaths paths, IActiveServiceCommandProvider serviceCommandProvider)
{
_paths = paths;
_serviceCommandProvider = serviceCommandProvider;
}
public string Path => _serviceCommandProvider.ActiveCommand.CacheStoragePath;
public string Path => _paths.CacheDirectory
.SubDirectory(_serviceCommandProvider.ActiveCommand.Name.ToLower()).FullName;
}

@ -1,18 +0,0 @@
using Autofac;
namespace Recyclarr.Command.Helpers;
internal static class CliTypeActivator
{
public static object ResolveType(IContainer container, Type typeToResolve)
{
var instance = container.Resolve(typeToResolve);
if (instance.GetType().IsAssignableTo<IServiceCommand>())
{
var activeServiceProvider = container.Resolve<IActiveServiceCommandProvider>();
activeServiceProvider.ActiveCommand = (IServiceCommand) instance;
}
return instance;
}
}

@ -1,6 +0,0 @@
namespace Recyclarr.Command;
public interface IRadarrCommand : IServiceCommand
{
bool ListCustomFormats { get; }
}

@ -5,6 +5,5 @@ public interface IServiceCommand
bool Preview { get; }
bool Debug { get; }
ICollection<string> Config { get; }
string CacheStoragePath { get; }
string Name { get; }
}

@ -1,7 +0,0 @@
namespace Recyclarr.Command;
public interface ISonarrCommand : IServiceCommand
{
bool ListReleaseProfiles { get; }
string? ListTerms { get; }
}

@ -1,6 +0,0 @@
namespace Recyclarr.Command.Initialization.Cleanup;
public interface IServiceCleaner
{
void Cleanup();
}

@ -1,18 +0,0 @@
using Recyclarr.Logging;
namespace Recyclarr.Command.Initialization.Cleanup;
internal class OldLogFileCleaner : IServiceCleaner
{
private readonly ILogJanitor _janitor;
public OldLogFileCleaner(ILogJanitor janitor)
{
_janitor = janitor;
}
public void Cleanup()
{
_janitor.DeleteOldestLogFiles(20);
}
}

@ -1,64 +1,58 @@
using System.IO.Abstractions;
using CliFx.Exceptions;
using Common;
using Serilog;
using TrashLib;
namespace Recyclarr.Command.Initialization;
public class DefaultAppDataSetup : IDefaultAppDataSetup
public class DefaultAppDataSetup
{
private readonly IEnvironment _env;
private readonly IAppPaths _paths;
private readonly IFileSystem _fs;
private readonly ILogger _log;
public DefaultAppDataSetup(IEnvironment env, IAppPaths paths, IFileSystem fs, ILogger log)
public DefaultAppDataSetup(IEnvironment env, IFileSystem fs)
{
_env = env;
_paths = paths;
_fs = fs;
_log = log;
}
public void SetupDefaultPath(string? appDataDirectoryOverride, bool forceCreate)
public IAppPaths CreateAppPaths(string? appDataDirectoryOverride = null, bool forceCreate = true)
{
var appDir = GetAppDataDirectory(appDataDirectoryOverride, forceCreate);
return new AppPaths(_fs.DirectoryInfo.FromDirectoryName(appDir));
}
private string GetAppDataDirectory(string? appDataDirectoryOverride, bool forceCreate)
{
// If a specific app data directory is not provided, use the following environment variable to find the path.
appDataDirectoryOverride ??= _env.GetEnvironmentVariable("RECYCLARR_APP_DATA");
// If the user did not explicitly specify an app data directory, perform some system introspection to verify if
// the user has a home directory.
if (string.IsNullOrEmpty(appDataDirectoryOverride))
// Ensure user-specified app data directory is created and use it.
if (!string.IsNullOrEmpty(appDataDirectoryOverride))
{
// If we can't even get the $HOME directory value, throw an exception. User must explicitly specify it with
// --app-data.
var home = _env.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrEmpty(home))
{
throw new CommandException(
"The system does not have a HOME directory, so the application cannot determine where to place " +
"data files. Please use the --app-data option to explicitly set a location for these files.");
}
return _fs.Directory.CreateDirectory(appDataDirectoryOverride).FullName;
}
// Set app data path to application directory value (e.g. `$HOME/.config` on Linux) and ensure it is
// created.
var appData = _env.GetFolderPath(Environment.SpecialFolder.ApplicationData,
forceCreate ? Environment.SpecialFolderOption.Create : Environment.SpecialFolderOption.None);
// If we can't even get the $HOME directory value, throw an exception. User must explicitly specify it with
// --app-data.
var home = _env.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrEmpty(home))
{
throw new CommandException(
"The system does not have a HOME directory, so the application cannot determine where to place " +
"data files. Please use the --app-data option to explicitly set a location for these files.");
}
if (string.IsNullOrEmpty(appData))
{
throw new DirectoryNotFoundException("Unable to find the default app data directory");
}
// Set app data path to application directory value (e.g. `$HOME/.config` on Linux) and ensure it is
// created.
var appData = _env.GetFolderPath(Environment.SpecialFolder.ApplicationData,
forceCreate ? Environment.SpecialFolderOption.Create : Environment.SpecialFolderOption.None);
_paths.SetAppDataPath(_fs.Path.Combine(appData, _paths.DefaultAppDataDirectoryName));
}
else
if (string.IsNullOrEmpty(appData))
{
// Ensure user-specified app data directory is created and use it.
var dir = _fs.Directory.CreateDirectory(appDataDirectoryOverride);
_paths.SetAppDataPath(dir.FullName);
throw new DirectoryNotFoundException("Unable to find the default app data directory");
}
_log.Debug("App Data Dir: {AppData}", _paths.GetAppDataPath());
return _fs.Path.Combine(appData, AppPaths.DefaultAppDataDirectoryName);
}
}

@ -1,6 +0,0 @@
namespace Recyclarr.Command.Initialization;
public interface IDefaultAppDataSetup
{
void SetupDefaultPath(string? appDataDirectoryOverride, bool forceCreate);
}

@ -1,6 +0,0 @@
namespace Recyclarr.Command.Initialization;
public interface IServiceInitializationAndCleanup
{
Task Execute(ServiceCommand cmd, Func<Task> logic);
}

@ -1,18 +0,0 @@
using Recyclarr.Migration;
namespace Recyclarr.Command.Initialization.Init;
internal class CheckMigrationNeeded : IServiceInitializer
{
private readonly IMigrationExecutor _migration;
public CheckMigrationNeeded(IMigrationExecutor migration)
{
_migration = migration;
}
public void Initialize(ServiceCommand cmd)
{
_migration.CheckNeededMigrations();
}
}

@ -1,6 +0,0 @@
namespace Recyclarr.Command.Initialization.Init;
public interface IServiceInitializer
{
void Initialize(ServiceCommand cmd);
}

@ -1,28 +0,0 @@
using System.IO.Abstractions;
using TrashLib;
namespace Recyclarr.Command.Initialization.Init;
public class InitializeAppDataPath : IServiceInitializer
{
private readonly IFileSystem _fs;
private readonly IAppPaths _paths;
private readonly IDefaultAppDataSetup _appDataSetup;
public InitializeAppDataPath(IFileSystem fs, IAppPaths paths, IDefaultAppDataSetup appDataSetup)
{
_fs = fs;
_paths = paths;
_appDataSetup = appDataSetup;
}
public void Initialize(ServiceCommand cmd)
{
_appDataSetup.SetupDefaultPath(cmd.AppDataDirectory, true);
// Initialize other directories used throughout the application
_fs.Directory.CreateDirectory(_paths.RepoDirectory);
_fs.Directory.CreateDirectory(_paths.CacheDirectory);
_fs.Directory.CreateDirectory(_paths.LogDirectory);
}
}

@ -1,84 +0,0 @@
using Common.Networking;
using Flurl.Http;
using Flurl.Http.Configuration;
using Newtonsoft.Json;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using TrashLib;
using TrashLib.Config.Settings;
using TrashLib.Extensions;
using TrashLib.Repo;
namespace Recyclarr.Command.Initialization.Init;
internal class ServiceInitializer : IServiceInitializer
{
private readonly ILogger _log;
private readonly LoggingLevelSwitch _loggingLevelSwitch;
private readonly ISettingsPersister _settingsPersister;
private readonly ISettingsProvider _settingsProvider;
private readonly IRepoUpdater _repoUpdater;
private readonly IConfigurationFinder _configFinder;
public ServiceInitializer(
ILogger log,
LoggingLevelSwitch loggingLevelSwitch,
ISettingsPersister settingsPersister,
ISettingsProvider settingsProvider,
IRepoUpdater repoUpdater,
IConfigurationFinder configFinder)
{
_log = log;
_loggingLevelSwitch = loggingLevelSwitch;
_settingsPersister = settingsPersister;
_settingsProvider = settingsProvider;
_repoUpdater = repoUpdater;
_configFinder = configFinder;
}
public void Initialize(ServiceCommand cmd)
{
// Must happen first because everything can use the logger.
_loggingLevelSwitch.MinimumLevel = cmd.Debug ? LogEventLevel.Debug : LogEventLevel.Information;
// Has to happen right after logging because stuff below may use settings.
_settingsPersister.Load();
SetupHttp();
_repoUpdater.UpdateRepo();
if (!cmd.Config.Any())
{
cmd.Config = new[] {_configFinder.FindConfigPath()};
}
}
private void SetupHttp()
{
FlurlHttp.Configure(settings =>
{
var jsonSettings = new JsonSerializerSettings
{
// This is important. If any DTOs are missing members, say, if Radarr or Sonarr adds one in a future
// version, this needs to fail to indicate that a software change is required. Otherwise, we lose
// state between when we request settings, and re-apply them again with a few properties modified.
MissingMemberHandling = MissingMemberHandling.Error,
// This makes sure that null properties, such as maxSize and preferredSize in Radarr
// Quality Definitions, do not get written out to JSON request bodies.
NullValueHandling = NullValueHandling.Ignore
};
settings.JsonSerializer = new NewtonsoftJsonSerializer(jsonSettings);
FlurlLogging.SetupLogging(settings, _log);
if (!_settingsProvider.Settings.EnableSslCertificateValidation)
{
_log.Warning(
"Security Risk: Certificate validation is being DISABLED because setting `enable_ssl_certificate_validation` is set to `false`");
settings.HttpClientFactory = new UntrustedCertClientFactory();
}
});
}
}

@ -1,30 +0,0 @@
using Autofac;
using Autofac.Extras.Ordering;
using Recyclarr.Command.Initialization.Cleanup;
using Recyclarr.Command.Initialization.Init;
namespace Recyclarr.Command.Initialization;
public class InitializationAutofacModule : Module
{
protected override void Load(ContainerBuilder builder)
{
base.Load(builder);
builder.RegisterType<ServiceInitializationAndCleanup>().As<IServiceInitializationAndCleanup>();
builder.RegisterType<DefaultAppDataSetup>().As<IDefaultAppDataSetup>();
// Initialization Services
builder.RegisterTypes(
typeof(InitializeAppDataPath),
typeof(CheckMigrationNeeded),
typeof(ServiceInitializer))
.As<IServiceInitializer>()
.OrderByRegistration();
// Cleanup Services
builder.RegisterTypes(
typeof(OldLogFileCleaner))
.As<IServiceCleaner>()
.OrderByRegistration();
}
}

@ -1,33 +0,0 @@
using MoreLinq.Extensions;
using Recyclarr.Command.Initialization.Cleanup;
using Recyclarr.Command.Initialization.Init;
namespace Recyclarr.Command.Initialization;
public class ServiceInitializationAndCleanup : IServiceInitializationAndCleanup
{
private readonly IOrderedEnumerable<IServiceInitializer> _initializers;
private readonly IOrderedEnumerable<IServiceCleaner> _cleaners;
public ServiceInitializationAndCleanup(
IOrderedEnumerable<IServiceInitializer> initializers,
IOrderedEnumerable<IServiceCleaner> cleaners)
{
_initializers = initializers;
_cleaners = cleaners;
}
public async Task Execute(ServiceCommand cmd, Func<Task> logic)
{
try
{
_initializers.ForEach(x => x.Initialize(cmd));
await logic();
}
finally
{
_cleaners.ForEach(x => x.Cleanup());
}
}
}

@ -1,43 +1,27 @@
using System.Text;
using CliFx;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using JetBrains.Annotations;
using Recyclarr.Command.Initialization;
using Recyclarr.Migration;
namespace Recyclarr.Command;
[Command("migrate", Description = "Perform any migration steps that may be needed between versions")]
[UsedImplicitly]
public class MigrateCommand : ICommand
public class MigrateCommand : BaseCommand
{
private readonly IMigrationExecutor _migration;
private readonly IDefaultAppDataSetup _appDataSetup;
[CommandOption("app-data", Description =
"Explicitly specify the location of the recyclarr application data directory. " +
"Mainly for usage in Docker; not recommended for normal use.")]
public override string? AppDataDirectory { get; set; }
[CommandOption("debug", 'd', Description =
"Display additional logs useful for development/debug purposes.")]
public bool Debug { get; [UsedImplicitly] set; } = false;
public MigrateCommand(IMigrationExecutor migration, IDefaultAppDataSetup appDataSetup)
public override Task Process(IServiceLocatorProxy container)
{
_migration = migration;
_appDataSetup = appDataSetup;
}
var migration = container.Resolve<IMigrationExecutor>();
public ValueTask ExecuteAsync(IConsole console)
{
_appDataSetup.SetupDefaultPath(null, false);
PerformMigrations();
return ValueTask.CompletedTask;
}
private void PerformMigrations()
{
try
{
_migration.PerformAllMigrationSteps(Debug);
migration.PerformAllMigrationSteps(Debug);
}
catch (MigrationException e)
{
@ -57,5 +41,7 @@ public class MigrateCommand : ICommand
throw new CommandException(msg.ToString());
}
return Task.CompletedTask;
}
}

@ -1,39 +1,53 @@
using CliFx.Attributes;
using JetBrains.Annotations;
using Recyclarr.Command.Initialization;
using Recyclarr.Command.Services;
using Recyclarr.Config;
using Serilog;
using TrashLib.Extensions;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat;
using TrashLib.Radarr.QualityDefinition;
namespace Recyclarr.Command;
[Command("radarr", Description = "Perform operations on a Radarr instance")]
[UsedImplicitly]
internal class RadarrCommand : ServiceCommand, IRadarrCommand
internal class RadarrCommand : ServiceCommand
{
private readonly Lazy<RadarrService> _service;
private readonly string? _cacheStoragePath;
[CommandOption("list-custom-formats", Description =
"List available custom formats from the guide in YAML format.")]
public bool ListCustomFormats { get; [UsedImplicitly] set; }
public override string Name => "Radarr";
public sealed override string CacheStoragePath
public override async Task Process(IServiceLocatorProxy container)
{
get => _cacheStoragePath ?? _service.Value.DefaultCacheStoragePath;
protected init => _cacheStoragePath = value;
}
await base.Process(container);
public RadarrCommand(
IServiceInitializationAndCleanup init,
Lazy<RadarrService> service)
: base(init)
{
_service = service;
}
var lister = container.Resolve<ICustomFormatLister>();
var log = container.Resolve<ILogger>();
var customFormatUpdaterFactory = container.Resolve<Func<ICustomFormatUpdater>>();
var qualityUpdaterFactory = container.Resolve<Func<IRadarrQualityDefinitionUpdater>>();
var configLoader = container.Resolve<IConfigurationLoader<RadarrConfiguration>>();
protected override async Task Process()
{
await _service.Value.Execute(this);
}
if (ListCustomFormats)
{
lister.ListCustomFormats();
return;
}
[CommandOption("list-custom-formats", Description =
"List available custom formats from the guide in YAML format.")]
public bool ListCustomFormats { get; [UsedImplicitly] set; }
foreach (var config in configLoader.LoadMany(Config, "radarr"))
{
log.Information("Processing server {Url}", FlurlLogging.SanitizeUrl(config.BaseUrl));
if (config.QualityDefinition != null)
{
await qualityUpdaterFactory().Process(Preview, config);
}
if (config.CustomFormats.Count > 0)
{
await customFormatUpdaterFactory().Process(Preview, config);
}
}
}
}

@ -1,23 +1,29 @@
using CliFx;
using System.Text;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using Common.Networking;
using Flurl.Http;
using Flurl.Http.Configuration;
using JetBrains.Annotations;
using Recyclarr.Command.Initialization;
using Newtonsoft.Json;
using Recyclarr.Command.Helpers;
using Recyclarr.Migration;
using Serilog;
using TrashLib;
using TrashLib.Config.Settings;
using TrashLib.Extensions;
using TrashLib.Repo;
using YamlDotNet.Core;
namespace Recyclarr.Command;
public abstract class ServiceCommand : ICommand, IServiceCommand
public abstract class ServiceCommand : BaseCommand, IServiceCommand
{
private readonly IServiceInitializationAndCleanup _init;
[CommandOption("preview", 'p', Description =
"Only display the processed markdown results without making any API calls.")]
public bool Preview { get; [UsedImplicitly] set; } = false;
[CommandOption("debug", 'd', Description =
"Display additional logs useful for development/debug purposes.")]
public bool Debug { get; [UsedImplicitly] set; } = false;
[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 `recyclarr.yml` in the same directory as the executable.")]
@ -26,18 +32,91 @@ public abstract class ServiceCommand : ICommand, IServiceCommand
[CommandOption("app-data", Description =
"Explicitly specify the location of the recyclarr application data directory. " +
"Mainly for usage in Docker; not recommended for normal use.")]
public string? AppDataDirectory { get; [UsedImplicitly] set; }
public override string? AppDataDirectory { get; [UsedImplicitly] set; }
public abstract string CacheStoragePath { get; [UsedImplicitly] protected init; }
public abstract string Name { get; }
protected ServiceCommand(IServiceInitializationAndCleanup init)
public sealed override async ValueTask ExecuteAsync(IConsole console)
{
_init = init;
try
{
await base.ExecuteAsync(console);
}
catch (YamlException e)
{
var message = e.InnerException is not null ? e.InnerException.Message : e.Message;
var msg = new StringBuilder();
msg.AppendLine($"Found Unrecognized YAML Property: {message}");
msg.AppendLine("Please remove the property quoted in the above message from your YAML file");
msg.AppendLine("Exiting due to invalid configuration");
throw new CommandException(msg.ToString());
}
catch (FlurlHttpException e)
{
throw new CommandException(
$"HTTP error while communicating with {Name}: {e.SanitizedExceptionMessage()}");
}
catch (Exception e) when (e is not CommandException)
{
throw new CommandException(e.ToString());
}
}
public async ValueTask ExecuteAsync(IConsole console)
=> await _init.Execute(this, Process);
public override Task Process(IServiceLocatorProxy container)
{
var log = container.Resolve<ILogger>();
var settingsPersister = container.Resolve<ISettingsPersister>();
var settingsProvider = container.Resolve<ISettingsProvider>();
var repoUpdater = container.Resolve<IRepoUpdater>();
var configFinder = container.Resolve<IConfigurationFinder>();
var commandProvider = container.Resolve<IActiveServiceCommandProvider>();
var migration = container.Resolve<IMigrationExecutor>();
commandProvider.ActiveCommand = this;
// Will throw if migration is required, otherwise just a warning is issued.
migration.CheckNeededMigrations();
// Stuff below may use settings.
settingsPersister.Load();
SetupHttp(log, settingsProvider);
repoUpdater.UpdateRepo();
if (!Config.Any())
{
Config = new[] {configFinder.FindConfigPath().FullName};
}
protected abstract Task Process();
return Task.CompletedTask;
}
private void SetupHttp(ILogger log, ISettingsProvider settingsProvider)
{
FlurlHttp.Configure(settings =>
{
var jsonSettings = new JsonSerializerSettings
{
// This is important. If any DTOs are missing members, say, if Radarr or Sonarr adds one in a future
// version, this needs to fail to indicate that a software change is required. Otherwise, we lose
// state between when we request settings, and re-apply them again with a few properties modified.
MissingMemberHandling = MissingMemberHandling.Error,
// This makes sure that null properties, such as maxSize and preferredSize in Radarr
// Quality Definitions, do not get written out to JSON request bodies.
NullValueHandling = NullValueHandling.Ignore
};
settings.JsonSerializer = new NewtonsoftJsonSerializer(jsonSettings);
FlurlLogging.SetupLogging(settings, log);
if (!settingsProvider.Settings.EnableSslCertificateValidation)
{
log.Warning(
"Security Risk: Certificate validation is being DISABLED because setting " +
"`enable_ssl_certificate_validation` is set to `false`");
settings.HttpClientFactory = new UntrustedCertClientFactory();
}
});
}
}

@ -1,65 +0,0 @@
using System.IO.Abstractions;
using Recyclarr.Config;
using Serilog;
using TrashLib;
using TrashLib.Extensions;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat;
using TrashLib.Radarr.QualityDefinition;
namespace Recyclarr.Command.Services;
public class RadarrService : ServiceBase<IRadarrCommand>
{
private readonly IConfigurationLoader<RadarrConfiguration> _configLoader;
private readonly Func<ICustomFormatUpdater> _customFormatUpdaterFactory;
private readonly ICustomFormatLister _lister;
private readonly IFileSystem _fs;
private readonly IAppPaths _paths;
private readonly ILogger _log;
private readonly Func<IRadarrQualityDefinitionUpdater> _qualityUpdaterFactory;
public RadarrService(
ILogger log,
IConfigurationLoader<RadarrConfiguration> configLoader,
Func<IRadarrQualityDefinitionUpdater> qualityUpdaterFactory,
Func<ICustomFormatUpdater> customFormatUpdaterFactory,
ICustomFormatLister lister,
IFileSystem fs,
IAppPaths paths)
{
_log = log;
_configLoader = configLoader;
_qualityUpdaterFactory = qualityUpdaterFactory;
_customFormatUpdaterFactory = customFormatUpdaterFactory;
_lister = lister;
_fs = fs;
_paths = paths;
}
public string DefaultCacheStoragePath => _fs.Path.Combine(_paths.CacheDirectory, "radarr");
protected override async Task Process(IRadarrCommand cmd)
{
if (cmd.ListCustomFormats)
{
_lister.ListCustomFormats();
return;
}
foreach (var config in _configLoader.LoadMany(cmd.Config, "radarr"))
{
_log.Information("Processing server {Url}", FlurlLogging.SanitizeUrl(config.BaseUrl));
if (config.QualityDefinition != null)
{
await _qualityUpdaterFactory().Process(cmd.Preview, config);
}
if (config.CustomFormats.Count > 0)
{
await _customFormatUpdaterFactory().Process(cmd.Preview, config);
}
}
}
}

@ -1,41 +0,0 @@
using System.Text;
using CliFx.Exceptions;
using Flurl.Http;
using TrashLib.Extensions;
using YamlDotNet.Core;
namespace Recyclarr.Command.Services;
/// <summary>
/// Mainly intended to handle common exception recovery logic between service command classes.
/// </summary>
public abstract class ServiceBase<T> where T : IServiceCommand
{
public async Task Execute(T cmd)
{
try
{
await Process(cmd);
}
catch (YamlException e)
{
var message = e.InnerException is not null ? e.InnerException.Message : e.Message;
var msg = new StringBuilder();
msg.AppendLine($"Found Unrecognized YAML Property: {message}");
msg.AppendLine("Please remove the property quoted in the above message from your YAML file");
msg.AppendLine("Exiting due to invalid configuration");
throw new CommandException(msg.ToString());
}
catch (FlurlHttpException e)
{
throw new CommandException(
$"HTTP error while communicating with {cmd.Name}: {e.SanitizedExceptionMessage()}");
}
catch (Exception e) when (e is not CommandException)
{
throw new CommandException(e.ToString());
}
}
protected abstract Task Process(T cmd);
}

@ -1,82 +0,0 @@
using System.IO.Abstractions;
using CliFx.Exceptions;
using Recyclarr.Config;
using Serilog;
using TrashLib;
using TrashLib.Extensions;
using TrashLib.Sonarr;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.QualityDefinition;
using TrashLib.Sonarr.ReleaseProfile;
namespace Recyclarr.Command.Services;
public class SonarrService : ServiceBase<ISonarrCommand>
{
private readonly ILogger _log;
private readonly IConfigurationLoader<SonarrConfiguration> _configLoader;
private readonly Func<IReleaseProfileUpdater> _profileUpdaterFactory;
private readonly Func<ISonarrQualityDefinitionUpdater> _qualityUpdaterFactory;
private readonly IReleaseProfileLister _lister;
private readonly IFileSystem _fs;
private readonly IAppPaths _paths;
public SonarrService(
ILogger log,
IConfigurationLoader<SonarrConfiguration> configLoader,
Func<IReleaseProfileUpdater> profileUpdaterFactory,
Func<ISonarrQualityDefinitionUpdater> qualityUpdaterFactory,
IReleaseProfileLister lister,
IFileSystem fs,
IAppPaths paths)
{
_log = log;
_configLoader = configLoader;
_profileUpdaterFactory = profileUpdaterFactory;
_qualityUpdaterFactory = qualityUpdaterFactory;
_lister = lister;
_fs = fs;
_paths = paths;
}
public string DefaultCacheStoragePath => _fs.Path.Combine(_paths.CacheDirectory, "sonarr");
protected override async Task Process(ISonarrCommand cmd)
{
if (cmd.ListReleaseProfiles)
{
_lister.ListReleaseProfiles();
return;
}
if (cmd.ListTerms != "empty")
{
if (!string.IsNullOrEmpty(cmd.ListTerms))
{
_lister.ListTerms(cmd.ListTerms);
}
else
{
throw new CommandException(
"The --list-terms option was specified without a Release Profile Trash ID specified");
}
return;
}
foreach (var config in _configLoader.LoadMany(cmd.Config, "sonarr"))
{
_log.Information("Processing server {Url}", FlurlLogging.SanitizeUrl(config.BaseUrl));
if (config.ReleaseProfiles.Count > 0)
{
await _profileUpdaterFactory().Process(cmd.Preview, config);
}
if (config.QualityDefinition.HasValue)
{
await _qualityUpdaterFactory().Process(cmd.Preview, config);
}
}
}
}

@ -1,19 +1,20 @@
using System.IO.Abstractions;
using CliFx.Attributes;
using CliFx.Exceptions;
using JetBrains.Annotations;
using Recyclarr.Command.Initialization;
using Recyclarr.Command.Services;
using TrashLib;
using Recyclarr.Config;
using Serilog;
using TrashLib.Extensions;
using TrashLib.Sonarr;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.QualityDefinition;
using TrashLib.Sonarr.ReleaseProfile;
namespace Recyclarr.Command;
[Command("sonarr", Description = "Perform operations on a Sonarr instance")]
[UsedImplicitly]
public class SonarrCommand : ServiceCommand, ISonarrCommand
public class SonarrCommand : ServiceCommand
{
private readonly Lazy<SonarrService> _service;
private readonly string? _cacheStoragePath;
[CommandOption("list-release-profiles", Description =
"List available release profiles from the guide in YAML format.")]
public bool ListReleaseProfiles { get; [UsedImplicitly] set; }
@ -25,26 +26,52 @@ public class SonarrCommand : ServiceCommand, ISonarrCommand
"Note that not every release profile has terms that may be filtered.")]
public string? ListTerms { get; [UsedImplicitly] set; } = "empty";
public sealed override string CacheStoragePath
{
get => _cacheStoragePath ?? _service.Value.DefaultCacheStoragePath;
protected init => _cacheStoragePath = value;
}
public override string Name => "Sonarr";
public SonarrCommand(
IServiceInitializationAndCleanup init,
Lazy<SonarrService> service,
IFileSystem fs,
IAppPaths paths)
: base(init)
public override async Task Process(IServiceLocatorProxy container)
{
_service = service;
}
await base.Process(container);
protected override async Task Process()
{
await _service.Value.Execute(this);
var lister = container.Resolve<IReleaseProfileLister>();
var profileUpdaterFactory = container.Resolve<Func<IReleaseProfileUpdater>>();
var qualityUpdaterFactory = container.Resolve<Func<ISonarrQualityDefinitionUpdater>>();
var configLoader = container.Resolve<IConfigurationLoader<SonarrConfiguration>>();
var log = container.Resolve<ILogger>();
if (ListReleaseProfiles)
{
lister.ListReleaseProfiles();
return;
}
if (ListTerms != "empty")
{
if (!string.IsNullOrEmpty(ListTerms))
{
lister.ListTerms(ListTerms);
}
else
{
throw new CommandException(
"The --list-terms option was specified without a Release Profile Trash ID specified");
}
return;
}
foreach (var config in configLoader.LoadMany(Config, "sonarr"))
{
log.Information("Processing server {Url}", FlurlLogging.SanitizeUrl(config.BaseUrl));
if (config.ReleaseProfiles.Count > 0)
{
await profileUpdaterFactory().Process(Preview, config);
}
if (config.QualityDefinition.HasValue)
{
await qualityUpdaterFactory().Process(Preview, config);
}
}
}
}

@ -8,13 +8,11 @@ using CliFx.Infrastructure;
using Common;
using Recyclarr.Command.Helpers;
using Recyclarr.Command.Initialization;
using Recyclarr.Command.Initialization.Init;
using Recyclarr.Command.Services;
using Recyclarr.Config;
using Recyclarr.Logging;
using Recyclarr.Migration;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using TrashLib;
using TrashLib.Cache;
using TrashLib.Config;
@ -27,25 +25,66 @@ using YamlDotNet.Serialization;
namespace Recyclarr;
public static class CompositionRoot
internal class CompositionRoot : ICompositionRoot
{
private static void SetupLogging(ContainerBuilder builder)
public IServiceLocatorProxy Setup(string? appDataDir, IConsole console, LogEventLevel logLevel)
{
return Setup(new ContainerBuilder(), appDataDir, console, logLevel);
}
public IServiceLocatorProxy Setup(ContainerBuilder builder, string? appDataDir, IConsole console,
LogEventLevel logLevel)
{
builder.RegisterInstance(console).As<IConsole>();
RegisterAppPaths(builder, appDataDir);
RegisterLogger(builder, logLevel);
builder.RegisterModule<SonarrAutofacModule>();
builder.RegisterModule<RadarrAutofacModule>();
builder.RegisterModule<VersionControlAutofacModule>();
builder.RegisterModule<MigrationAutofacModule>();
// Needed for Autofac.Extras.Ordering
builder.RegisterSource<OrderedRegistrationSource>();
builder.RegisterModule<CacheAutofacModule>();
builder.RegisterType<CacheStoragePath>().As<ICacheStoragePath>();
builder.RegisterType<RepoUpdater>().As<IRepoUpdater>();
ConfigurationRegistrations(builder);
CommandRegistrations(builder);
builder.Register(_ => AutoMapperConfig.Setup()).SingleInstance();
return new ServiceLocatorProxy(builder.Build());
}
private void RegisterLogger(ContainerBuilder builder, LogEventLevel logLevel)
{
builder.RegisterType<LogJanitor>().As<ILogJanitor>();
builder.RegisterType<DelayedFileSink>().As<IDelayedFileSink>();
builder.RegisterType<LoggingLevelSwitch>().SingleInstance();
builder.RegisterType<LoggerFactory>();
builder.Register(c => c.Resolve<LoggerFactory>().Create())
builder.Register(c => c.Resolve<LoggerFactory>().Create(logLevel))
.As<ILogger>()
.SingleInstance();
}
private void RegisterAppPaths(ContainerBuilder builder, string? appDataDir)
{
builder.RegisterModule<CommonAutofacModule>();
builder.RegisterType<FileSystem>().As<IFileSystem>();
builder.RegisterType<DefaultAppDataSetup>();
builder.Register(c => c.Resolve<DefaultAppDataSetup>().CreateAppPaths(appDataDir))
.As<IAppPaths>()
.SingleInstance();
}
private static void ConfigurationRegistrations(ContainerBuilder builder)
{
builder.RegisterModule<ConfigAutofacModule>();
builder.RegisterType<ObjectFactory>().As<IObjectFactory>();
builder.RegisterType<AppPaths>().As<IAppPaths>().SingleInstance();
builder.RegisterType<ConfigurationFinder>().As<IConfigurationFinder>();
builder.RegisterGeneric(typeof(ConfigurationLoader<>))
@ -55,10 +94,6 @@ public static class CompositionRoot
private static void CommandRegistrations(ContainerBuilder builder)
{
builder.RegisterType<SonarrService>();
builder.RegisterType<RadarrService>();
builder.RegisterType<ServiceInitializer>().As<IServiceInitializer>();
// Register all types deriving from CliFx's ICommand. These are all of our supported subcommands.
builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly())
.AssignableTo<ICommand>();
@ -71,37 +106,4 @@ public static class CompositionRoot
.As<IActiveServiceCommandProvider>()
.SingleInstance();
}
public static IContainer Setup()
{
return Setup(new ContainerBuilder());
}
public static IContainer Setup(ContainerBuilder builder)
{
// Needed for Autofac.Extras.Ordering
builder.RegisterSource<OrderedRegistrationSource>();
builder.RegisterType<FileSystem>().As<IFileSystem>();
builder.RegisterType<SystemConsole>().As<IConsole>().SingleInstance();
builder.RegisterModule<CacheAutofacModule>();
builder.RegisterType<CacheStoragePath>().As<ICacheStoragePath>();
builder.RegisterType<RepoUpdater>().As<IRepoUpdater>();
ConfigurationRegistrations(builder);
CommandRegistrations(builder);
SetupLogging(builder);
builder.RegisterModule<CommonAutofacModule>();
builder.RegisterModule<SonarrAutofacModule>();
builder.RegisterModule<RadarrAutofacModule>();
builder.RegisterModule<VersionControlAutofacModule>();
builder.RegisterModule<MigrationAutofacModule>();
builder.RegisterModule<InitializationAutofacModule>();
builder.Register(_ => AutoMapperConfig.Setup()).SingleInstance();
return builder.Build();
}
}

@ -20,12 +20,13 @@ public class ConfigurationFinder : IConfigurationFinder
_fs = fs;
}
public string FindConfigPath()
public IFileInfo FindConfigPath()
{
var newPath = _paths.ConfigPath;
var oldPath = _fs.Path.Combine(_appContext.BaseDirectory, _paths.DefaultConfigFilename);
var oldPath = _fs.DirectoryInfo.FromDirectoryName(_appContext.BaseDirectory)
.File(AppPaths.DefaultConfigFilename);
if (!_fs.File.Exists(oldPath))
if (!oldPath.Exists)
{
return newPath;
}

@ -0,0 +1,13 @@
using Autofac;
using CliFx.Infrastructure;
using Serilog.Events;
namespace Recyclarr;
public interface ICompositionRoot
{
IServiceLocatorProxy Setup(string? appDataDir, IConsole console, LogEventLevel logLevel);
IServiceLocatorProxy Setup(ContainerBuilder builder, string? appDataDir, IConsole console,
LogEventLevel logLevel);
}

@ -0,0 +1,9 @@
using Autofac;
namespace Recyclarr;
public interface IServiceLocatorProxy : IDisposable
{
ILifetimeScope Container { get; }
T Resolve<T>() where T : notnull;
}

@ -1,48 +0,0 @@
using System.IO.Abstractions;
using Serilog.Events;
using Serilog.Formatting;
using Serilog.Formatting.Display;
using TrashLib;
namespace Recyclarr.Logging;
public sealed class DelayedFileSink : IDelayedFileSink
{
private readonly IAppPaths _paths;
private readonly Lazy<StreamWriter> _stream;
private ITextFormatter? _formatter;
public DelayedFileSink(IAppPaths paths, IFileSystem fs)
{
_paths = paths;
_stream = new Lazy<StreamWriter>(() =>
{
var logPath = fs.Path.Combine(_paths.LogDirectory, $"trash_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.log");
return fs.File.CreateText(logPath);
});
}
public void Emit(LogEvent logEvent)
{
if (!_paths.IsAppDataPathValid)
{
return;
}
_formatter?.Format(logEvent, _stream.Value);
_stream.Value.Flush();
}
public void Dispose()
{
if (_stream.IsValueCreated)
{
_stream.Value.Close();
}
}
public void SetTemplate(string template)
{
_formatter = new MessageTemplateTextFormatter(template);
}
}

@ -1,8 +0,0 @@
using Serilog.Core;
namespace Recyclarr.Logging;
public interface IDelayedFileSink : ILogEventSink, IDisposable
{
void SetTemplate(string template);
}

@ -1,24 +1,19 @@
using System.IO.Abstractions;
using TrashLib;
namespace Recyclarr.Logging;
public class LogJanitor : ILogJanitor
{
private readonly IFileSystem _fs;
private readonly IAppPaths _paths;
public LogJanitor(IFileSystem fs, IAppPaths paths)
public LogJanitor(IAppPaths paths)
{
_fs = fs;
_paths = paths;
}
public void DeleteOldestLogFiles(int numberOfNewestToKeep)
{
var dir = _fs.Directory.CreateDirectory(_paths.LogDirectory);
foreach (var file in dir.GetFiles()
foreach (var file in _paths.LogDirectory.GetFiles()
.OrderByDescending(f => f.Name)
.Skip(numberOfNewestToKeep))
{

@ -1,30 +1,29 @@
using System.IO.Abstractions;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using TrashLib;
namespace Recyclarr.Logging;
public class LoggerFactory
{
private const string ConsoleTemplate = "[{Level:u3}] {Message:lj}{NewLine}{Exception}";
private readonly IAppPaths _paths;
private readonly LoggingLevelSwitch _logLevel;
private readonly Func<IDelayedFileSink> _fileSinkFactory;
private const string ConsoleTemplate = "[{Level:u3}] {Message:lj}{NewLine}{Exception}";
public LoggerFactory(LoggingLevelSwitch logLevel, Func<IDelayedFileSink> fileSinkFactory)
public LoggerFactory(IAppPaths paths)
{
_logLevel = logLevel;
_fileSinkFactory = fileSinkFactory;
_paths = paths;
}
public ILogger Create()
public ILogger Create(LogEventLevel level)
{
var fileSink = _fileSinkFactory();
fileSink.SetTemplate(ConsoleTemplate);
var logPath = _paths.LogDirectory.File($"trash_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.log");
return new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console(outputTemplate: ConsoleTemplate, levelSwitch: _logLevel)
.WriteTo.Sink(fileSink)
.MinimumLevel.Is(level)
.WriteTo.Console(outputTemplate: ConsoleTemplate)
.WriteTo.File(logPath.FullName)
.CreateLogger();
}
}

@ -13,34 +13,44 @@ namespace Recyclarr.Migration.Steps;
public class MigrateTrashUpdaterAppDataDir : IMigrationStep
{
private readonly IFileSystem _fs;
private readonly Lazy<string> _newPath, _oldPath;
private readonly IAppPaths _paths;
public int Order => 20;
public bool Required => true;
public string Description
=> $"Merge files from old app data directory `{GetOldPath()}` into `{GetNewPath()}` and delete old directory";
=> $"Merge files from old app data directory `{OldPath}` into `{NewPath}` and delete old directory";
public IReadOnlyCollection<string> Remediation => new[]
{
$"Check if `{GetNewPath()}` already exists. If so, manually copy all files from `{GetOldPath()}` and delete it to fix the error.",
$"Ensure Recyclarr has permission to recursively delete {GetOldPath()}",
$"Ensure Recyclarr has permission to create and move files into {GetNewPath()}"
$"Check if `{NewPath}` already exists. If so, manually copy all files from `{OldPath}` and delete it to fix the error.",
$"Ensure Recyclarr has permission to recursively delete {OldPath}",
$"Ensure Recyclarr has permission to create and move files into {NewPath}"
};
private string GetNewPath() => _newPath.Value;
private string GetOldPath() => _oldPath.Value;
public MigrateTrashUpdaterAppDataDir(IFileSystem fs, IAppPaths paths)
{
_fs = fs;
_paths = paths;
}
// Will be something like `/home/user/.config/recyclarr`.
_newPath = new Lazy<string>(paths.GetAppDataPath);
_oldPath = new Lazy<string>(() => _fs.Path.Combine(_fs.Path.GetDirectoryName(GetNewPath()), "trash-updater"));
public IDirectoryInfo NewPath
{
get
{
// Will be something like `/home/user/.config/recyclarr`.
var path = _paths.AppDataDirectory;
path.Refresh();
return path;
}
}
public bool CheckIfNeeded() => _fs.Directory.Exists(GetOldPath());
public IDirectoryInfo OldPath => NewPath.Parent.SubDirectory("trash-updater");
public bool CheckIfNeeded()
{
return OldPath.Exists;
}
public void Execute(IConsole? console)
{
@ -48,31 +58,30 @@ public class MigrateTrashUpdaterAppDataDir : IMigrationStep
MoveFile("recyclarr.yml");
MoveFile("settings.yml");
var oldDir = _fs.DirectoryInfo.FromDirectoryName(GetOldPath());
if (oldDir.Exists)
if (OldPath.Exists)
{
oldDir.Delete(true);
OldPath.Delete(true);
}
}
private void MoveDirectory(string directory, IConsole? console)
{
var oldPath = _fs.Path.Combine(GetOldPath(), directory);
if (_fs.Directory.Exists(oldPath))
var oldPath = OldPath.SubDirectory(directory);
if (oldPath.Exists)
{
_fs.MergeDirectory(
oldPath,
_fs.Path.Combine(GetNewPath(), directory),
NewPath.SubDirectory(directory),
console);
}
}
private void MoveFile(string file)
{
var recyclarrYaml = _fs.FileInfo.FromFileName(_fs.Path.Combine(GetOldPath(), file));
var recyclarrYaml = OldPath.File(file);
if (recyclarrYaml.Exists)
{
recyclarrYaml.MoveTo(_fs.Path.Combine(GetNewPath(), file));
recyclarrYaml.MoveTo(NewPath.File(file).FullName);
}
}
}

@ -1,47 +1,33 @@
using System.Diagnostics;
using System.Text;
using Autofac;
using Autofac.Core;
using CliFx;
using CliFx.Infrastructure;
using Recyclarr.Command.Helpers;
using Recyclarr.Command;
namespace Recyclarr;
internal static class Program
{
private static IContainer? _container;
private static string ExecutableName => Process.GetCurrentProcess().ProcessName;
public static async Task<int> Main()
{
_container = CompositionRoot.Setup();
BaseCommand.CompositionRoot = new CompositionRoot();
var status = await new CliApplicationBuilder()
.AddCommands(GetRegisteredCommandTypes())
.AddCommands(GetAllCommandTypes())
.SetExecutableName(ExecutableName)
.SetVersion(BuildVersion())
.UseTypeActivator(type => CliTypeActivator.ResolveType(_container, type))
.UseConsole(_container.Resolve<IConsole>())
.Build()
.RunAsync();
return status;
}
private static IEnumerable<Type> GetRegisteredCommandTypes()
private static IEnumerable<Type> GetAllCommandTypes()
{
if (_container is null)
{
throw new NullReferenceException("DI Container was null during migration process");
}
return _container.ComponentRegistry.Registrations
.SelectMany(x => x.Services)
.OfType<TypedService>()
.Select(x => x.ServiceType)
.Where(x => x.IsAssignableTo<ICommand>());
return typeof(Program).Assembly.GetTypes()
.Where(x => x.IsAssignableTo<ICommand>() && !x.IsAbstract);
}
private static string BuildVersion()

@ -0,0 +1,23 @@
using Autofac;
namespace Recyclarr;
public sealed class ServiceLocatorProxy : IServiceLocatorProxy
{
public ServiceLocatorProxy(ILifetimeScope container)
{
Container = container;
}
public ILifetimeScope Container { get; }
public T Resolve<T>() where T : notnull
{
return Container.Resolve<T>();
}
public void Dispose()
{
Container.Dispose();
}
}

@ -0,0 +1,27 @@
using System.IO.Abstractions;
using System.IO.Abstractions.Extensions;
using System.IO.Abstractions.TestingHelpers;
using AutoFixture;
namespace TestLibrary.AutoFixture;
public class MockFileSystemSpecimenBuilder : ICustomization
{
public void Customize(IFixture fixture)
{
var fs = new MockFileSystem();
fixture.Inject(fs);
fixture.Customize<IFileInfo>(x => x.FromFactory(() =>
{
var name = $"MockFile-{fixture.Create<string>()}";
return fs.CurrentDirectory().File(name);
}));
fixture.Customize<IDirectoryInfo>(x => x.FromFactory(() =>
{
var name = $"MockDirectory-{fixture.Create<string>()}";
return fs.CurrentDirectory().SubDirectory(name);
}));
}
}

@ -12,10 +12,9 @@ public static class NSubstituteFixture
OmitAutoProperties = true
};
fixture.Customize(new AutoNSubstituteCustomization
{
ConfigureMembers = true
});
fixture
.Customize(new AutoNSubstituteCustomization {ConfigureMembers = true})
.Customize(new MockFileSystemSpecimenBuilder());
return fixture;
}

@ -1,3 +1,4 @@
using System.IO.Abstractions;
using System.IO.Abstractions.TestingHelpers;
namespace TestLibrary;
@ -9,8 +10,18 @@ public static class MockFileSystemExtensions
fs.AddFile(FileUtils.NormalizePath(path), new MockFileData(""));
}
public static void AddFileNoData(this MockFileSystem fs, IFileInfo path)
{
fs.AddFile(path.FullName, new MockFileData(""));
}
public static void AddDirectory2(this MockFileSystem fs, string path)
{
fs.AddDirectory(FileUtils.NormalizePath(path));
}
public static void AddDirectory(this MockFileSystem fs, IDirectoryInfo path)
{
fs.AddDirectory(path.FullName);
}
}

@ -10,6 +10,7 @@
<PackageReference Include="NSubstitute" />
<PackageReference Include="NUnit" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Extensions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj" />

@ -3,6 +3,7 @@ using AutoFixture.NUnit3;
using FluentAssertions;
using NSubstitute;
using NUnit.Framework;
using Recyclarr.TestLibrary;
using TestLibrary.AutoFixture;
using TrashLib.Config;
using TrashLib.Config.Settings;
@ -17,26 +18,21 @@ public class SettingsPersisterTest
[Test, AutoMockData]
public void Load_should_create_settings_file_if_not_exists(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fileSystem,
[Frozen] IAppPaths paths,
[Frozen(Matching.ImplementedInterfaces)] TestAppPaths paths,
SettingsPersister sut)
{
paths.SettingsPath.Returns("test_path");
sut.Load();
fileSystem.AllFiles.Should().ContainSingle(x => x.EndsWith(paths.SettingsPath));
fileSystem.AllFiles.Should().ContainSingle(x => x == paths.SettingsPath.FullName);
}
[Test, AutoMockData]
public void Load_defaults_when_file_does_not_exist(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fileSystem,
[Frozen(Matching.ImplementedInterfaces)] YamlSerializerFactory serializerFactory,
[Frozen(Matching.ImplementedInterfaces)] SettingsProvider settingsProvider,
[Frozen] IAppPaths paths,
[Frozen(Matching.ImplementedInterfaces)] TestAppPaths paths,
SettingsPersister sut)
{
paths.SettingsPath.Returns("test_path");
sut.Load();
var expectedSettings = new SettingsValues();
@ -46,8 +42,8 @@ public class SettingsPersisterTest
[Test, AutoMockData]
public void Load_data_correctly_when_file_exists(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fileSystem,
[Frozen] IYamlSerializerFactory serializerFactory,
[Frozen] IAppPaths paths,
[Frozen(Matching.ImplementedInterfaces)] TestAppPaths paths,
[Frozen] IDeserializer deserializer,
SettingsPersister sut)
{
// For this test, it doesn't really matter if the YAML data matches what SettingsValue expects;
@ -56,10 +52,7 @@ public class SettingsPersisterTest
repository:
clone_url: http://the_url.com
";
var deserializer = Substitute.For<IDeserializer>();
serializerFactory.CreateDeserializer().Returns(deserializer);
paths.SettingsPath.Returns("test_path");
fileSystem.AddFile(paths.SettingsPath, new MockFileData(expectedYamlData));
fileSystem.AddFile(paths.SettingsPath.FullName, new MockFileData(expectedYamlData));
sut.Load();

@ -1,10 +1,9 @@
using System.IO.Abstractions;
using System.IO.Abstractions.Extensions;
using System.IO.Abstractions.TestingHelpers;
using AutoFixture.NUnit3;
using FluentAssertions;
using NSubstitute;
using NUnit.Framework;
using Recyclarr.TestLibrary;
using TestLibrary.AutoFixture;
using TrashLib.Radarr.CustomFormat.Guide;
using TrashLib.TestLibrary;
@ -17,16 +16,15 @@ public class LocalRepoCustomFormatJsonParserTest
{
[Test, AutoMockData]
public void Get_custom_format_json_works(
[Frozen] IAppPaths paths,
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen(Matching.ImplementedInterfaces)] TestAppPaths paths,
LocalRepoCustomFormatJsonParser sut)
{
var jsonDir = fs.CurrentDirectory()
var jsonDir = paths.RepoDirectory
.SubDirectory("docs")
.SubDirectory("json")
.SubDirectory("radarr");
paths.RepoDirectory.Returns(fs.CurrentDirectory().FullName);
fs.AddFile(jsonDir.File("first.json").FullName, new MockFileData("{'name':'first','trash_id':'1'}"));
fs.AddFile(jsonDir.File("second.json").FullName, new MockFileData("{'name':'second','trash_id':'2'}"));

@ -1,11 +1,10 @@
using System.IO.Abstractions;
using System.IO.Abstractions.Extensions;
using System.IO.Abstractions.TestingHelpers;
using AutoFixture.NUnit3;
using FluentAssertions;
using Newtonsoft.Json;
using NSubstitute;
using NUnit.Framework;
using Recyclarr.TestLibrary;
using TestLibrary;
using TestLibrary.AutoFixture;
using TrashLib.Sonarr.ReleaseProfile;
@ -19,8 +18,8 @@ public class LocalRepoReleaseProfileJsonParserTest
{
[Test, AutoMockData]
public void Get_custom_format_json_works(
[Frozen] IAppPaths paths,
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fileSystem,
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen(Matching.ImplementedInterfaces)] TestAppPaths paths,
LocalRepoReleaseProfileJsonParser sut)
{
static ReleaseProfileData MakeMockObject(string term) => new()
@ -39,9 +38,13 @@ public class LocalRepoReleaseProfileJsonParserTest
var mockData1 = MakeMockObject("first");
var mockData2 = MakeMockObject("second");
paths.RepoDirectory.Returns("");
fileSystem.AddFile("docs/json/sonarr/first.json", MockFileData(mockData1));
fileSystem.AddFile("docs/json/sonarr/second.json", MockFileData(mockData2));
var baseDir = paths.RepoDirectory
.SubDirectory("docs")
.SubDirectory("json")
.SubDirectory("sonarr");
fs.AddFile(baseDir.File("first.json").FullName, MockFileData(mockData1));
fs.AddFile(baseDir.File("second.json").FullName, MockFileData(mockData2));
var results = sut.GetReleaseProfileData();
@ -55,11 +58,10 @@ public class LocalRepoReleaseProfileJsonParserTest
[Test, AutoMockData]
public void Json_exceptions_do_not_interrupt_parsing_other_files(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen] IAppPaths paths,
[Frozen(Matching.ImplementedInterfaces)] TestAppPaths paths,
LocalRepoReleaseProfileJsonParser sut)
{
paths.RepoDirectory.Returns("");
var rootPath = fs.CurrentDirectory()
var rootPath = paths.RepoDirectory
.SubDirectory("docs")
.SubDirectory("json")
.SubDirectory("sonarr");

@ -4,6 +4,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Recyclarr.TestLibrary\Recyclarr.TestLibrary.csproj" />
<ProjectReference Include="..\TestLibrary\TestLibrary.csproj" />
<ProjectReference Include="..\TrashLib.TestLibrary\TrashLib.TestLibrary.csproj" />
<ProjectReference Include="..\TrashLib\TrashLib.csproj" />

@ -1,5 +1,3 @@
using System.IO.Abstractions;
namespace TrashLib.Config.Settings;
public class SettingsPersister : ISettingsPersister
@ -7,18 +5,15 @@ public class SettingsPersister : ISettingsPersister
private readonly IAppPaths _paths;
private readonly ISettingsProvider _settingsProvider;
private readonly IYamlSerializerFactory _serializerFactory;
private readonly IFileSystem _fileSystem;
public SettingsPersister(
IAppPaths paths,
ISettingsProvider settingsProvider,
IYamlSerializerFactory serializerFactory,
IFileSystem fileSystem)
IYamlSerializerFactory serializerFactory)
{
_paths = paths;
_settingsProvider = settingsProvider;
_serializerFactory = serializerFactory;
_fileSystem = fileSystem;
}
public void Load()
@ -30,12 +25,13 @@ public class SettingsPersister : ISettingsPersister
private string LoadOrCreateSettingsFile()
{
if (!_fileSystem.File.Exists(_paths.SettingsPath))
if (!_paths.SettingsPath.Exists)
{
CreateDefaultSettingsFile();
}
return _fileSystem.File.ReadAllText(_paths.SettingsPath);
using var stream = _paths.SettingsPath.OpenText();
return stream.ReadToEnd();
}
private void CreateDefaultSettingsFile()
@ -47,6 +43,8 @@ public class SettingsPersister : ISettingsPersister
"# For the settings file reference guide, visit the link to the wiki below:\n" +
"# https://github.com/recyclarr/recyclarr/wiki/Settings-Reference\n";
_fileSystem.File.WriteAllText(_paths.SettingsPath, fileData);
_paths.SettingsPath.Directory.Create();
using var stream = _paths.SettingsPath.CreateText();
stream.Write(fileData);
}
}

@ -1,15 +1,13 @@
using System.IO.Abstractions;
namespace TrashLib;
public interface IAppPaths
{
void SetAppDataPath(string path);
string GetAppDataPath();
string ConfigPath { get; }
string SettingsPath { get; }
string LogDirectory { get; }
string RepoDirectory { get; }
string CacheDirectory { get; }
string DefaultConfigFilename { get; }
bool IsAppDataPathValid { get; }
string DefaultAppDataDirectoryName { get; }
IDirectoryInfo AppDataDirectory { get; }
IFileInfo ConfigPath { get; }
IFileInfo SettingsPath { get; }
IDirectoryInfo LogDirectory { get; }
IDirectoryInfo RepoDirectory { get; }
IDirectoryInfo CacheDirectory { get; }
}

@ -1,6 +1,8 @@
using System.IO.Abstractions;
namespace TrashLib;
public interface IConfigurationFinder
{
string FindConfigPath();
IFileInfo FindConfigPath();
}

@ -11,20 +11,18 @@ namespace TrashLib.Radarr.CustomFormat.Guide;
public class LocalRepoCustomFormatJsonParser : IRadarrGuideService
{
private readonly IFileSystem _fs;
private readonly IAppPaths _paths;
private readonly ILogger _log;
public LocalRepoCustomFormatJsonParser(IFileSystem fs, IAppPaths paths, ILogger log)
public LocalRepoCustomFormatJsonParser(IAppPaths paths, ILogger log)
{
_fs = fs;
_paths = paths;
_log = log;
}
public IEnumerable<CustomFormatData> GetCustomFormatData()
{
var jsonDir = _fs.DirectoryInfo.FromDirectoryName(_paths.RepoDirectory)
var jsonDir = _paths.RepoDirectory
.SubDirectory("docs")
.SubDirectory("json")
.SubDirectory("radarr");

@ -6,22 +6,20 @@ namespace TrashLib.Radarr.QualityDefinition;
internal class RadarrQualityDefinitionGuideParser : IRadarrQualityDefinitionGuideParser
{
private readonly IFileSystem _fs;
private readonly IAppPaths _paths;
private readonly Regex _regexHeader = new(@"^#+", RegexOptions.Compiled);
private readonly Regex _regexTableRow =
new(@"\| *(.*?) *\| *([\d.]+) *\| *([\d.]+) *\|", RegexOptions.Compiled);
public RadarrQualityDefinitionGuideParser(IFileSystem fs, IAppPaths paths)
public RadarrQualityDefinitionGuideParser(IAppPaths paths)
{
_fs = fs;
_paths = paths;
}
public async Task<string> GetMarkdownData()
{
var repoDir = _fs.DirectoryInfo.FromDirectoryName(_paths.RepoDirectory);
var repoDir = _paths.RepoDirectory;
var file = repoDir
.SubDirectory("docs")
.SubDirectory("Radarr")

@ -1,7 +1,9 @@
using System.IO.Abstractions;
namespace TrashLib.Repo;
public interface IRepoUpdater
{
string RepoPath { get; }
IDirectoryInfo RepoPath { get; }
void UpdateRepo();
}

@ -1,3 +1,4 @@
using System.IO.Abstractions;
using Common;
using LibGit2Sharp;
using Serilog;
@ -28,7 +29,7 @@ public class RepoUpdater : IRepoUpdater
_settingsProvider = settingsProvider;
}
public string RepoPath => _paths.RepoDirectory;
public IDirectoryInfo RepoPath => _paths.RepoDirectory;
public void UpdateRepo()
{
@ -38,7 +39,7 @@ public class RepoUpdater : IRepoUpdater
if (exception is not null)
{
_log.Information("Deleting local git repo and retrying git operation...");
_fileUtils.DeleteReadOnlyDirectory(RepoPath);
_fileUtils.DeleteReadOnlyDirectory(RepoPath.FullName);
exception = CheckoutAndUpdateRepo();
if (exception is not null)
@ -58,7 +59,7 @@ public class RepoUpdater : IRepoUpdater
try
{
using var repo = _repositoryFactory.CreateAndCloneIfNeeded(cloneUrl, RepoPath, branch);
using var repo = _repositoryFactory.CreateAndCloneIfNeeded(cloneUrl, RepoPath.FullName, branch);
repo.ForceCheckout(branch);
repo.Fetch();
repo.ResetHard($"origin/{branch}");

@ -11,18 +11,16 @@ internal class SonarrQualityDefinitionGuideParser : ISonarrQualityDefinitionGuid
private readonly Regex _regexTableRow =
new(@"\| *(.*?) *\| *([\d.]+) *\| *([\d.]+) *\|", RegexOptions.Compiled);
private readonly IFileSystem _fs;
private readonly IAppPaths _paths;
public SonarrQualityDefinitionGuideParser(IFileSystem fs, IAppPaths paths)
public SonarrQualityDefinitionGuideParser(IAppPaths paths)
{
_fs = fs;
_paths = paths;
}
public async Task<string> GetMarkdownData()
{
var repoDir = _fs.DirectoryInfo.FromDirectoryName(_paths.RepoDirectory);
var repoDir = _paths.RepoDirectory;
var file = repoDir
.SubDirectory("docs")
.SubDirectory("Sonarr")

@ -9,14 +9,12 @@ namespace TrashLib.Sonarr.ReleaseProfile.Guide;
public class LocalRepoReleaseProfileJsonParser : ISonarrGuideService
{
private readonly IFileSystem _fs;
private readonly IAppPaths _paths;
private readonly ILogger _log;
private readonly Lazy<IEnumerable<ReleaseProfileData>> _data;
public LocalRepoReleaseProfileJsonParser(IFileSystem fs, IAppPaths paths, ILogger log)
public LocalRepoReleaseProfileJsonParser(IAppPaths paths, ILogger log)
{
_fs = fs;
_paths = paths;
_log = log;
_data = new Lazy<IEnumerable<ReleaseProfileData>>(GetReleaseProfileDataImpl);
@ -25,8 +23,12 @@ public class LocalRepoReleaseProfileJsonParser : ISonarrGuideService
private IEnumerable<ReleaseProfileData> GetReleaseProfileDataImpl()
{
var converter = new TermDataConverter();
var jsonDir = _fs.Path.Combine(_paths.RepoDirectory, "docs/json/sonarr");
var tasks = _fs.Directory.GetFiles(jsonDir, "*.json")
var jsonDir = _paths.RepoDirectory
.SubDirectory("docs")
.SubDirectory("json")
.SubDirectory("sonarr");
var tasks = jsonDir.GetFiles("*.json")
.Select(f => LoadAndParseFile(f, converter));
return Task.WhenAll(tasks).Result
@ -34,11 +36,12 @@ public class LocalRepoReleaseProfileJsonParser : ISonarrGuideService
.Choose(x => x is not null ? (true, x) : default);
}
private async Task<ReleaseProfileData?> LoadAndParseFile(string file, params JsonConverter[] converters)
private async Task<ReleaseProfileData?> LoadAndParseFile(IFileInfo file, params JsonConverter[] converters)
{
try
{
var json = await _fs.File.ReadAllTextAsync(file);
using var stream = file.OpenText();
var json = await stream.ReadToEndAsync();
return JsonConvert.DeserializeObject<ReleaseProfileData>(json, converters);
}
catch (JsonException e)
@ -53,11 +56,11 @@ public class LocalRepoReleaseProfileJsonParser : ISonarrGuideService
return null;
}
private void HandleJsonException(JsonException exception, string file)
private void HandleJsonException(JsonException exception, IFileInfo file)
{
_log.Warning(exception,
"Failed to parse Sonarr JSON file (This likely indicates a bug that should be " +
"reported in the TRaSH repo): {File}", _fs.Path.GetFileName(file));
"reported in the TRaSH repo): {File}", file.Name);
}
public ReleaseProfileData? GetUnfilteredProfileById(string trashId)

Loading…
Cancel
Save