refactor: Centralize repo updating

Repo updating is also a little more robust now.
json-serializing-nullable-fields-issue
Robert Dailey 9 months ago
parent b5c49d81c5
commit ef8ae7dd48

@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Print date & time log at the end of each completed instance sync (#165). - Print date & time log at the end of each completed instance sync (#165).
- Add status indicator when cloning or updating git repos.
### Changed ### Changed

@ -6,6 +6,8 @@ namespace Recyclarr.Cli.Console.Commands;
public class BaseCommandSettings : CommandSettings public class BaseCommandSettings : CommandSettings
{ {
public CancellationToken CancellationToken { get; set; }
[CommandOption("-d|--debug")] [CommandOption("-d|--debug")]
[Description("Show debug logs in console output.")] [Description("Show debug logs in console output.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)] [UsedImplicitly(ImplicitUseKindFlags.Assign)]

@ -4,6 +4,7 @@ using JetBrains.Annotations;
using Recyclarr.Cli.Console.Settings; using Recyclarr.Cli.Console.Settings;
using Recyclarr.Cli.Processors.Config; using Recyclarr.Cli.Processors.Config;
using Recyclarr.TrashLib.ExceptionTypes; using Recyclarr.TrashLib.ExceptionTypes;
using Recyclarr.TrashLib.Repo;
using Spectre.Console.Cli; using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console.Commands; namespace Recyclarr.Cli.Console.Commands;
@ -13,6 +14,7 @@ namespace Recyclarr.Cli.Console.Commands;
public class ConfigCreateCommand : AsyncCommand<ConfigCreateCommand.CliSettings> public class ConfigCreateCommand : AsyncCommand<ConfigCreateCommand.CliSettings>
{ {
private readonly IConfigCreationProcessor _processor; private readonly IConfigCreationProcessor _processor;
private readonly IMultiRepoUpdater _repoUpdater;
private readonly ILogger _log; private readonly ILogger _log;
[UsedImplicitly] [UsedImplicitly]
@ -40,9 +42,10 @@ public class ConfigCreateCommand : AsyncCommand<ConfigCreateCommand.CliSettings>
public bool Force { get; init; } public bool Force { get; init; }
} }
public ConfigCreateCommand(ILogger log, IConfigCreationProcessor processor) public ConfigCreateCommand(ILogger log, IConfigCreationProcessor processor, IMultiRepoUpdater repoUpdater)
{ {
_processor = processor; _processor = processor;
_repoUpdater = repoUpdater;
_log = log; _log = log;
} }
@ -50,7 +53,8 @@ public class ConfigCreateCommand : AsyncCommand<ConfigCreateCommand.CliSettings>
{ {
try try
{ {
await _processor.Process(settings); await _repoUpdater.UpdateAllRepositories(settings.CancellationToken);
_processor.Process(settings);
} }
catch (FileExistsException e) catch (FileExistsException e)
{ {

@ -6,6 +6,7 @@ using Recyclarr.Cli.Processors.Config;
using Recyclarr.TrashLib.Config.Listers; using Recyclarr.TrashLib.Config.Listers;
using Recyclarr.TrashLib.Config.Parsing.ErrorHandling; using Recyclarr.TrashLib.Config.Parsing.ErrorHandling;
using Recyclarr.TrashLib.ExceptionTypes; using Recyclarr.TrashLib.ExceptionTypes;
using Recyclarr.TrashLib.Repo;
using Spectre.Console.Cli; using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console.Commands; namespace Recyclarr.Cli.Console.Commands;
@ -16,6 +17,7 @@ public class ConfigListCommand : AsyncCommand<ConfigListCommand.CliSettings>
{ {
private readonly ILogger _log; private readonly ILogger _log;
private readonly ConfigListProcessor _processor; private readonly ConfigListProcessor _processor;
private readonly IMultiRepoUpdater _repoUpdater;
[SuppressMessage("Design", "CA1034:Nested types should not be visible")] [SuppressMessage("Design", "CA1034:Nested types should not be visible")]
public class CliSettings : BaseCommandSettings public class CliSettings : BaseCommandSettings
@ -26,17 +28,19 @@ public class ConfigListCommand : AsyncCommand<ConfigListCommand.CliSettings>
public ConfigCategory ListCategory { get; [UsedImplicitly] init; } = ConfigCategory.Local; public ConfigCategory ListCategory { get; [UsedImplicitly] init; } = ConfigCategory.Local;
} }
public ConfigListCommand(ILogger log, ConfigListProcessor processor) public ConfigListCommand(ILogger log, ConfigListProcessor processor, IMultiRepoUpdater repoUpdater)
{ {
_log = log; _log = log;
_processor = processor; _processor = processor;
_repoUpdater = repoUpdater;
} }
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings) public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{ {
try try
{ {
await _processor.Process(settings.ListCategory); await _repoUpdater.UpdateAllRepositories(settings.CancellationToken);
_processor.Process(settings.ListCategory);
} }
catch (FileExistsException e) catch (FileExistsException e)
{ {

@ -14,10 +14,10 @@ namespace Recyclarr.Cli.Console.Commands;
[UsedImplicitly] [UsedImplicitly]
[Description("List custom formats in the guide for a particular service.")] [Description("List custom formats in the guide for a particular service.")]
internal class ListCustomFormatsCommand : AsyncCommand<ListCustomFormatsCommand.CliSettings> public class ListCustomFormatsCommand : AsyncCommand<ListCustomFormatsCommand.CliSettings>
{ {
private readonly CustomFormatDataLister _lister; private readonly CustomFormatDataLister _lister;
private readonly ITrashGuidesRepo _repo; private readonly IMultiRepoUpdater _repoUpdater;
[UsedImplicitly] [UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")] [SuppressMessage("Design", "CA1034:Nested types should not be visible")]
@ -37,15 +37,15 @@ internal class ListCustomFormatsCommand : AsyncCommand<ListCustomFormatsCommand.
public bool Raw { get; init; } = false; public bool Raw { get; init; } = false;
} }
public ListCustomFormatsCommand(CustomFormatDataLister lister, ITrashGuidesRepo repo) public ListCustomFormatsCommand(CustomFormatDataLister lister, IMultiRepoUpdater repoUpdater)
{ {
_lister = lister; _lister = lister;
_repo = repo; _repoUpdater = repoUpdater;
} }
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings) public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{ {
await _repo.Update(); await _repoUpdater.UpdateAllRepositories(settings.CancellationToken);
_lister.List(settings); _lister.List(settings);
return 0; return 0;
} }

@ -12,10 +12,10 @@ namespace Recyclarr.Cli.Console.Commands;
#pragma warning disable CS8765 #pragma warning disable CS8765
[UsedImplicitly] [UsedImplicitly]
[Description("List quality definitions in the guide for a particular service.")] [Description("List quality definitions in the guide for a particular service.")]
internal class ListQualitiesCommand : AsyncCommand<ListQualitiesCommand.CliSettings> public class ListQualitiesCommand : AsyncCommand<ListQualitiesCommand.CliSettings>
{ {
private readonly QualitySizeDataLister _lister; private readonly QualitySizeDataLister _lister;
private readonly ITrashGuidesRepo _repoUpdater; private readonly IMultiRepoUpdater _repoUpdater;
[UsedImplicitly] [UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")] [SuppressMessage("Design", "CA1034:Nested types should not be visible")]
@ -27,15 +27,15 @@ internal class ListQualitiesCommand : AsyncCommand<ListQualitiesCommand.CliSetti
public SupportedServices Service { get; init; } public SupportedServices Service { get; init; }
} }
public ListQualitiesCommand(QualitySizeDataLister lister, ITrashGuidesRepo repo) public ListQualitiesCommand(QualitySizeDataLister lister, IMultiRepoUpdater repoUpdater)
{ {
_lister = lister; _lister = lister;
_repoUpdater = repo; _repoUpdater = repoUpdater;
} }
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings) public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{ {
await _repoUpdater.Update(); await _repoUpdater.UpdateAllRepositories(settings.CancellationToken);
_lister.ListQualities(settings.Service); _lister.ListQualities(settings.Service);
return 0; return 0;
} }

@ -11,11 +11,11 @@ namespace Recyclarr.Cli.Console.Commands;
[UsedImplicitly] [UsedImplicitly]
[Description("List Sonarr release profiles in the guide for a particular service.")] [Description("List Sonarr release profiles in the guide for a particular service.")]
internal class ListReleaseProfilesCommand : AsyncCommand<ListReleaseProfilesCommand.CliSettings> public class ListReleaseProfilesCommand : AsyncCommand<ListReleaseProfilesCommand.CliSettings>
{ {
private readonly ILogger _log; private readonly ILogger _log;
private readonly ReleaseProfileDataLister _lister; private readonly ReleaseProfileDataLister _lister;
private readonly ITrashGuidesRepo _repoUpdater; private readonly IMultiRepoUpdater _repoUpdater;
[UsedImplicitly] [UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")] [SuppressMessage("Design", "CA1034:Nested types should not be visible")]
@ -29,19 +29,19 @@ internal class ListReleaseProfilesCommand : AsyncCommand<ListReleaseProfilesComm
public string? ListTerms { get; init; } public string? ListTerms { get; init; }
} }
public ListReleaseProfilesCommand(ILogger log, ReleaseProfileDataLister lister, ITrashGuidesRepo repo) public ListReleaseProfilesCommand(ILogger log, ReleaseProfileDataLister lister, IMultiRepoUpdater repoUpdater)
{ {
_log = log; _log = log;
_lister = lister; _lister = lister;
_repoUpdater = repo; _repoUpdater = repoUpdater;
} }
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings) public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{ {
await _repoUpdater.UpdateAllRepositories(settings.CancellationToken);
try try
{ {
await _repoUpdater.Update();
if (settings.ListTerms is not null) if (settings.ListTerms is not null)
{ {
// Ignore nullability of ListTerms since the Settings.Validate() method will check for null/empty. // Ignore nullability of ListTerms since the Settings.Validate() method will check for null/empty.

@ -16,7 +16,7 @@ namespace Recyclarr.Cli.Console.Commands;
public class SyncCommand : AsyncCommand<SyncCommand.CliSettings> public class SyncCommand : AsyncCommand<SyncCommand.CliSettings>
{ {
private readonly IMigrationExecutor _migration; private readonly IMigrationExecutor _migration;
private readonly ITrashGuidesRepo _repoUpdater; private readonly IMultiRepoUpdater _repoUpdater;
private readonly ISyncProcessor _syncProcessor; private readonly ISyncProcessor _syncProcessor;
[UsedImplicitly] [UsedImplicitly]
@ -48,10 +48,10 @@ public class SyncCommand : AsyncCommand<SyncCommand.CliSettings>
public IReadOnlyCollection<string> Instances => InstancesOption; public IReadOnlyCollection<string> Instances => InstancesOption;
} }
public SyncCommand(IMigrationExecutor migration, ITrashGuidesRepo repo, ISyncProcessor syncProcessor) public SyncCommand(IMigrationExecutor migration, IMultiRepoUpdater repoUpdater, ISyncProcessor syncProcessor)
{ {
_migration = migration; _migration = migration;
_repoUpdater = repo; _repoUpdater = repoUpdater;
_syncProcessor = syncProcessor; _syncProcessor = syncProcessor;
} }
@ -61,7 +61,7 @@ public class SyncCommand : AsyncCommand<SyncCommand.CliSettings>
// Will throw if migration is required, otherwise just a warning is issued. // Will throw if migration is required, otherwise just a warning is issued.
_migration.CheckNeededMigrations(); _migration.CheckNeededMigrations();
await _repoUpdater.Update(); await _repoUpdater.UpdateAllRepositories(settings.CancellationToken);
return (int) await _syncProcessor.ProcessConfigs(settings); return (int) await _syncProcessor.ProcessConfigs(settings);
} }

@ -0,0 +1,41 @@
namespace Recyclarr.Cli.Console.Helpers;
// Taken from: https://github.com/spectreconsole/spectre.console/issues/701#issuecomment-1081834778
internal sealed class ConsoleAppCancellationTokenSource
{
private readonly CancellationTokenSource _cts = new();
public CancellationToken Token => _cts.Token;
public ConsoleAppCancellationTokenSource()
{
System.Console.CancelKeyPress += OnCancelKeyPress;
AppDomain.CurrentDomain.ProcessExit += OnProcessExit;
using var _ = _cts.Token.Register(() =>
{
AppDomain.CurrentDomain.ProcessExit -= OnProcessExit;
System.Console.CancelKeyPress -= OnCancelKeyPress;
});
}
private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
{
// NOTE: cancel event, don't terminate the process
e.Cancel = true;
_cts.Cancel();
}
private void OnProcessExit(object? sender, EventArgs e)
{
if (_cts.IsCancellationRequested)
{
// NOTE: SIGINT (cancel key was pressed, this shouldn't ever actually hit however, as we remove the event
// handler upon cancellation of the `cancellationSource`)
return;
}
_cts.Cancel();
}
}

@ -2,6 +2,7 @@
using System.Reactive.Linq; using System.Reactive.Linq;
using System.Reactive.Subjects; using System.Reactive.Subjects;
using Recyclarr.Cli.Console.Commands; using Recyclarr.Cli.Console.Commands;
using Recyclarr.Cli.Console.Helpers;
using Recyclarr.TrashLib.Startup; using Recyclarr.TrashLib.Startup;
using Serilog.Core; using Serilog.Core;
using Serilog.Events; using Serilog.Events;
@ -14,6 +15,7 @@ public class CliInterceptor : ICommandInterceptor
private readonly LoggingLevelSwitch _loggingLevelSwitch; private readonly LoggingLevelSwitch _loggingLevelSwitch;
private readonly AppDataPathProvider _appDataPathProvider; private readonly AppDataPathProvider _appDataPathProvider;
private readonly Subject<Unit> _interceptedSubject = new(); private readonly Subject<Unit> _interceptedSubject = new();
private readonly ConsoleAppCancellationTokenSource _ct = new();
public IObservable<Unit> OnIntercepted => _interceptedSubject.AsObservable(); public IObservable<Unit> OnIntercepted => _interceptedSubject.AsObservable();
@ -49,6 +51,8 @@ public class CliInterceptor : ICommandInterceptor
private void HandleBaseCommand(BaseCommandSettings cmd) private void HandleBaseCommand(BaseCommandSettings cmd)
{ {
cmd.CancellationToken = _ct.Token;
_loggingLevelSwitch.MinimumLevel = cmd.Debug switch _loggingLevelSwitch.MinimumLevel = cmd.Debug switch
{ {
true => LogEventLevel.Debug, true => LogEventLevel.Debug,

@ -12,7 +12,7 @@ public class ConfigCreationProcessor : IConfigCreationProcessor
_creators = creators; _creators = creators;
} }
public async Task Process(ICreateConfigSettings settings) public void Process(ICreateConfigSettings settings)
{ {
var creator = _creators.FirstOrDefault(x => x.CanHandle(settings)); var creator = _creators.FirstOrDefault(x => x.CanHandle(settings));
if (creator is null) if (creator is null)
@ -20,6 +20,6 @@ public class ConfigCreationProcessor : IConfigCreationProcessor
throw new FatalException("Unable to determine which config creation logic to use"); throw new FatalException("Unable to determine which config creation logic to use");
} }
await creator.Create(settings); creator.Create(settings);
} }
} }

@ -14,7 +14,7 @@ public class ConfigListProcessor
_configListers = configListers; _configListers = configListers;
} }
public async Task Process(ConfigCategory listCategory) public void Process(ConfigCategory listCategory)
{ {
_log.Debug("Listing configuration for category {Category}", listCategory); _log.Debug("Listing configuration for category {Category}", listCategory);
if (!_configListers.TryGetValue(listCategory, out var lister)) if (!_configListers.TryGetValue(listCategory, out var lister))
@ -22,6 +22,6 @@ public class ConfigListProcessor
throw new ArgumentOutOfRangeException(nameof(listCategory), listCategory, "Unknown list category"); throw new ArgumentOutOfRangeException(nameof(listCategory), listCategory, "Unknown list category");
} }
await lister.List(); lister.List();
} }
} }

@ -4,5 +4,5 @@ namespace Recyclarr.Cli.Processors.Config;
public interface IConfigCreationProcessor public interface IConfigCreationProcessor
{ {
Task Process(ICreateConfigSettings settings); void Process(ICreateConfigSettings settings);
} }

@ -5,5 +5,5 @@ namespace Recyclarr.Cli.Processors.Config;
public interface IConfigCreator public interface IConfigCreator
{ {
bool CanHandle(ICreateConfigSettings settings); bool CanHandle(ICreateConfigSettings settings);
Task Create(ICreateConfigSettings settings); void Create(ICreateConfigSettings settings);
} }

@ -27,7 +27,7 @@ public class LocalConfigCreator : IConfigCreator
return true; return true;
} }
public async Task Create(ICreateConfigSettings settings) public void Create(ICreateConfigSettings settings)
{ {
var configFile = settings.Path is null var configFile = settings.Path is null
? _paths.AppDataDirectory.File("recyclarr.yml") ? _paths.AppDataDirectory.File("recyclarr.yml")
@ -39,10 +39,10 @@ public class LocalConfigCreator : IConfigCreator
} }
configFile.CreateParentDirectory(); configFile.CreateParentDirectory();
await using var stream = configFile.CreateText(); using var stream = configFile.CreateText();
var ymlData = _resources.ReadData("config-template.yml"); var ymlData = _resources.ReadData("config-template.yml");
await stream.WriteAsync(ymlData); stream.Write(ymlData);
_log.Information("Created configuration at: {Path}", configFile.FullName); _log.Information("Created configuration at: {Path}", configFile.FullName);
} }

@ -29,11 +29,11 @@ public class TemplateConfigCreator : IConfigCreator
return settings.Templates.Any(); return settings.Templates.Any();
} }
public async Task Create(ICreateConfigSettings settings) public void Create(ICreateConfigSettings settings)
{ {
_log.Debug("Creating config from templates: {Templates}", settings.Templates); _log.Debug("Creating config from templates: {Templates}", settings.Templates);
var matchingTemplateData = (await _templates.LoadTemplateData()) var matchingTemplateData = _templates.LoadTemplateData()
.IntersectBy(settings.Templates, path => path.Id, StringComparer.CurrentCultureIgnoreCase) .IntersectBy(settings.Templates, path => path.Id, StringComparer.CurrentCultureIgnoreCase)
.Select(x => x.TemplateFile); .Select(x => x.TemplateFile);

@ -105,4 +105,25 @@ public static class FileSystemExtensions
dir.Delete(true); dir.Delete(true);
} }
public static void DeleteReadOnlyDirectory(this IDirectoryInfo directory)
{
if (!directory.Exists)
{
return;
}
foreach (var subdirectory in directory.EnumerateDirectories())
{
DeleteReadOnlyDirectory(subdirectory);
}
foreach (var fileInfo in directory.EnumerateFiles())
{
fileInfo.Attributes = FileAttributes.Normal;
fileInfo.Delete();
}
directory.Delete();
}
} }

@ -1,35 +0,0 @@
using System.IO.Abstractions;
namespace Recyclarr.Common;
public class FileUtilities : IFileUtilities
{
private readonly IFileSystem _fileSystem;
public FileUtilities(IFileSystem fileSystem)
{
_fileSystem = fileSystem;
}
public void DeleteReadOnlyDirectory(string directory)
{
if (!_fileSystem.Directory.Exists(directory))
{
return;
}
foreach (var subdirectory in _fileSystem.Directory.EnumerateDirectories(directory))
{
DeleteReadOnlyDirectory(subdirectory);
}
foreach (var fileName in Directory.EnumerateFiles(directory))
{
var fileInfo = _fileSystem.FileInfo.New(fileName);
fileInfo.Attributes = FileAttributes.Normal;
fileInfo.Delete();
}
_fileSystem.Directory.Delete(directory);
}
}

@ -1,6 +0,0 @@
namespace Recyclarr.Common;
public interface IFileUtilities
{
void DeleteReadOnlyDirectory(string directory);
}

@ -26,7 +26,7 @@ public class ConfigLocalLister : IConfigLister
_paths = paths; _paths = paths;
} }
public Task List() public void List()
{ {
var tree = new Tree(_paths.AppDataDirectory.ToString()!); var tree = new Tree(_paths.AppDataDirectory.ToString()!);
@ -54,7 +54,6 @@ public class ConfigLocalLister : IConfigLister
_console.WriteLine(); _console.WriteLine();
_console.Write(tree); _console.Write(tree);
return Task.CompletedTask;
} }
private string MakeRelative(IFileInfo path) private string MakeRelative(IFileInfo path)

@ -16,9 +16,9 @@ public class ConfigTemplateLister : IConfigLister
_guideService = guideService; _guideService = guideService;
} }
public async Task List() public void List()
{ {
var data = await _guideService.LoadTemplateData(); var data = _guideService.LoadTemplateData();
var table = new Table(); var table = new Table();
var empty = new Markup(""); var empty = new Markup("");

@ -2,5 +2,5 @@ namespace Recyclarr.TrashLib.Config.Listers;
public interface IConfigLister public interface IConfigLister
{ {
Task List(); void List();
} }

@ -31,10 +31,8 @@ public class ConfigTemplateGuideService : IConfigTemplateGuideService
_repo = repo; _repo = repo;
} }
public async Task<IReadOnlyCollection<TemplatePath>> LoadTemplateData() public IReadOnlyCollection<TemplatePath> LoadTemplateData()
{ {
await _repo.Update();
var templatesPath = _repo.Path.File("templates.json"); var templatesPath = _repo.Path.File("templates.json");
if (!templatesPath.Exists) if (!templatesPath.Exists)
{ {

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

@ -2,10 +2,11 @@ using System.IO.Abstractions;
using Recyclarr.Common.Extensions; using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Settings; using Recyclarr.TrashLib.Settings;
using Recyclarr.TrashLib.Startup; using Recyclarr.TrashLib.Startup;
using Serilog.Context;
namespace Recyclarr.TrashLib.Repo; namespace Recyclarr.TrashLib.Repo;
public class ConfigTemplatesRepo : IConfigTemplatesRepo public class ConfigTemplatesRepo : IConfigTemplatesRepo, IUpdateableRepo
{ {
private readonly IRepoUpdater _repoUpdater; private readonly IRepoUpdater _repoUpdater;
private readonly ISettingsProvider _settings; private readonly ISettingsProvider _settings;
@ -19,8 +20,9 @@ public class ConfigTemplatesRepo : IConfigTemplatesRepo
public IDirectoryInfo Path { get; } public IDirectoryInfo Path { get; }
public Task Update() public Task Update(CancellationToken token)
{ {
return _repoUpdater.UpdateRepo(Path, _settings.Settings.Repositories.ConfigTemplates); using var logScope = LogContext.PushProperty(LogProperty.Scope, "Config Templates Repo");
return _repoUpdater.UpdateRepo(Path, _settings.Settings.Repositories.ConfigTemplates, token);
} }
} }

@ -0,0 +1,29 @@
using Spectre.Console;
namespace Recyclarr.TrashLib.Repo;
public class ConsoleMultiRepoUpdater : IMultiRepoUpdater
{
private readonly IAnsiConsole _console;
private readonly IReadOnlyCollection<IUpdateableRepo> _repos;
public ConsoleMultiRepoUpdater(IAnsiConsole console, IReadOnlyCollection<IUpdateableRepo> repos)
{
_console = console;
_repos = repos;
}
public async Task UpdateAllRepositories(CancellationToken token)
{
var options = new ParallelOptions
{
CancellationToken = token,
MaxDegreeOfParallelism = 3
};
await _console.Status().StartAsync("Updating Git Repositories...", async _ =>
{
await Parallel.ForEachAsync(_repos, options, async (repo, innerToken) => await repo.Update(innerToken));
});
}
}

@ -5,5 +5,4 @@ namespace Recyclarr.TrashLib.Repo;
public interface IConfigTemplatesRepo public interface IConfigTemplatesRepo
{ {
IDirectoryInfo Path { get; } IDirectoryInfo Path { get; }
Task Update();
} }

@ -0,0 +1,6 @@
namespace Recyclarr.TrashLib.Repo;
public interface IMultiRepoUpdater
{
Task UpdateAllRepositories(CancellationToken token);
}

@ -5,5 +5,5 @@ namespace Recyclarr.TrashLib.Repo;
public interface IRepoUpdater public interface IRepoUpdater
{ {
Task UpdateRepo(IDirectoryInfo repoPath, IRepositorySettings repoSettings); Task UpdateRepo(IDirectoryInfo repoPath, IRepositorySettings repoSettings, CancellationToken token);
} }

@ -5,5 +5,4 @@ namespace Recyclarr.TrashLib.Repo;
public interface ITrashGuidesRepo public interface ITrashGuidesRepo
{ {
IDirectoryInfo Path { get; } IDirectoryInfo Path { get; }
Task Update();
} }

@ -0,0 +1,6 @@
namespace Recyclarr.TrashLib.Repo;
public interface IUpdateableRepo
{
Task Update(CancellationToken token);
}

@ -8,9 +8,12 @@ public class RepoAutofacModule : Module
{ {
base.Load(builder); base.Load(builder);
builder.RegisterType<ConfigTemplatesRepo>().As<IConfigTemplatesRepo>(); // Unique Repo Registrations
builder.RegisterType<TrashGuidesRepo>().As<ITrashGuidesRepo>(); builder.RegisterType<ConfigTemplatesRepo>().As<IConfigTemplatesRepo>().As<IUpdateableRepo>();
builder.RegisterType<TrashGuidesRepo>().As<ITrashGuidesRepo>().As<IUpdateableRepo>();
builder.RegisterType<RepoUpdater>().As<IRepoUpdater>(); builder.RegisterType<RepoUpdater>().As<IRepoUpdater>();
builder.RegisterType<ConsoleMultiRepoUpdater>().As<IMultiRepoUpdater>();
builder.RegisterType<RepoMetadataBuilder>().As<IRepoMetadataBuilder>().InstancePerLifetimeScope(); builder.RegisterType<RepoMetadataBuilder>().As<IRepoMetadataBuilder>().InstancePerLifetimeScope();
} }
} }

@ -1,5 +1,5 @@
using System.IO.Abstractions; using System.IO.Abstractions;
using Recyclarr.Common; using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Repo.VersionControl; using Recyclarr.TrashLib.Repo.VersionControl;
using Recyclarr.TrashLib.Settings; using Recyclarr.TrashLib.Settings;
@ -9,36 +9,49 @@ public class RepoUpdater : IRepoUpdater
{ {
private readonly ILogger _log; private readonly ILogger _log;
private readonly IGitRepositoryFactory _repositoryFactory; private readonly IGitRepositoryFactory _repositoryFactory;
private readonly IFileUtilities _fileUtils;
public RepoUpdater( public RepoUpdater(ILogger log, IGitRepositoryFactory repositoryFactory)
ILogger log,
IGitRepositoryFactory repositoryFactory,
IFileUtilities fileUtils)
{ {
_log = log; _log = log;
_repositoryFactory = repositoryFactory; _repositoryFactory = repositoryFactory;
_fileUtils = fileUtils;
} }
public async Task UpdateRepo(IDirectoryInfo repoPath, IRepositorySettings repoSettings) public async Task UpdateRepo(
IDirectoryInfo repoPath,
IRepositorySettings repoSettings,
CancellationToken token)
{ {
// Assume failure until it succeeds, to simplify the catch handlers.
var succeeded = false;
// Retry only once if there's a failure. This gives us an opportunity to delete the git repository and start // Retry only once if there's a failure. This gives us an opportunity to delete the git repository and start
// fresh. // fresh.
try try
{ {
await CheckoutAndUpdateRepo(repoPath, repoSettings); await CheckoutAndUpdateRepo(repoPath, repoSettings, token);
succeeded = true;
} }
catch (GitCmdException e) catch (GitCmdException e)
{ {
_log.Debug(e, "Non-zero exit code {ExitCode} while executing Git command: {Error}", e.ExitCode, e.Error); _log.Debug(e, "Non-zero exit code {ExitCode} while executing Git command: {Error}", e.ExitCode, e.Error);
_log.Warning("Deleting local git repo '{Repodir}' and retrying git operation due to error", repoPath.Name); }
_fileUtils.DeleteReadOnlyDirectory(repoPath.FullName); catch (InvalidGitRepoException e)
await CheckoutAndUpdateRepo(repoPath, repoSettings); {
_log.Debug(e, "Git repository is not valid (missing files in `.git` directory)");
}
if (!succeeded)
{
_log.Warning("Deleting local git repo and retrying git operation due to error");
repoPath.DeleteReadOnlyDirectory();
await CheckoutAndUpdateRepo(repoPath, repoSettings, token);
} }
} }
private async Task CheckoutAndUpdateRepo(IDirectoryInfo repoPath, IRepositorySettings repoSettings) private async Task CheckoutAndUpdateRepo(
IDirectoryInfo repoPath,
IRepositorySettings repoSettings,
CancellationToken token)
{ {
var cloneUrl = repoSettings.CloneUrl; var cloneUrl = repoSettings.CloneUrl;
var branch = repoSettings.Branch; var branch = repoSettings.Branch;
@ -49,12 +62,12 @@ public class RepoUpdater : IRepoUpdater
_log.Warning("Using explicit SHA1 for local repository: {Sha1}", repoSettings.Sha1); _log.Warning("Using explicit SHA1 for local repository: {Sha1}", repoSettings.Sha1);
} }
using var repo = await _repositoryFactory.CreateAndCloneIfNeeded(cloneUrl, repoPath, branch); using var repo = await _repositoryFactory.CreateAndCloneIfNeeded(cloneUrl, repoPath, branch, token);
await repo.ForceCheckout(branch); await repo.ForceCheckout(token, branch);
try try
{ {
await repo.Fetch(); await repo.Fetch(token);
} }
catch (GitCmdException e) catch (GitCmdException e)
{ {
@ -65,6 +78,6 @@ public class RepoUpdater : IRepoUpdater
repoPath.Name); repoPath.Name);
} }
await repo.ResetHard(repoSettings.Sha1 ?? $"origin/{branch}"); await repo.ResetHard(token, repoSettings.Sha1 ?? $"origin/{branch}");
} }
} }

@ -2,10 +2,11 @@ using System.IO.Abstractions;
using Recyclarr.Common.Extensions; using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Settings; using Recyclarr.TrashLib.Settings;
using Recyclarr.TrashLib.Startup; using Recyclarr.TrashLib.Startup;
using Serilog.Context;
namespace Recyclarr.TrashLib.Repo; namespace Recyclarr.TrashLib.Repo;
public class TrashGuidesRepo : ITrashGuidesRepo public class TrashGuidesRepo : ITrashGuidesRepo, IUpdateableRepo
{ {
private readonly IRepoUpdater _repoUpdater; private readonly IRepoUpdater _repoUpdater;
private readonly ISettingsProvider _settings; private readonly ISettingsProvider _settings;
@ -19,8 +20,9 @@ public class TrashGuidesRepo : ITrashGuidesRepo
public IDirectoryInfo Path { get; } public IDirectoryInfo Path { get; }
public Task Update() public Task Update(CancellationToken token)
{ {
return _repoUpdater.UpdateRepo(Path, _settings.Settings.Repositories.TrashGuides); using var logScope = LogContext.PushProperty(LogProperty.Scope, "Trash Guides Repo");
return _repoUpdater.UpdateRepo(Path, _settings.Settings.Repositories.TrashGuides, token);
} }
} }

@ -14,3 +14,11 @@ public class GitCmdException : Exception
ExitCode = exitCode; ExitCode = exitCode;
} }
} }
public class InvalidGitRepoException : Exception
{
public InvalidGitRepoException(string? message)
: base(message)
{
}
}

@ -1,9 +1,12 @@
using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions; using System.IO.Abstractions;
using System.Text; using System.Text;
using CliWrap; using CliWrap;
namespace Recyclarr.TrashLib.Repo.VersionControl; namespace Recyclarr.TrashLib.Repo.VersionControl;
[SuppressMessage("Design", "CA1068:CancellationToken parameters must come last", Justification =
"Doesn't mix well with `params` (which has to be at the end)")]
public sealed class GitRepository : IGitRepository public sealed class GitRepository : IGitRepository
{ {
private readonly ILogger _log; private readonly ILogger _log;
@ -17,12 +20,12 @@ public sealed class GitRepository : IGitRepository
_workDir = workDir; _workDir = workDir;
} }
private Task RunGitCmd(params string[] args) private Task RunGitCmd(CancellationToken token, params string[] args)
{ {
return RunGitCmd((ICollection<string>) args); return RunGitCmd(token, (ICollection<string>) args);
} }
private async Task RunGitCmd(ICollection<string> args) private async Task RunGitCmd(CancellationToken token, ICollection<string> args)
{ {
_log.Debug("Executing git command with args: {Args}", args); _log.Debug("Executing git command with args: {Args}", args);
@ -39,7 +42,7 @@ public sealed class GitRepository : IGitRepository
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(error)) .WithStandardErrorPipe(PipeTarget.ToStringBuilder(error))
.WithWorkingDirectory(_workDir.FullName); .WithWorkingDirectory(_workDir.FullName);
var result = await cli.ExecuteAsync(); var result = await cli.ExecuteAsync(token);
_log.Debug("Command Output: {Output}", output.ToString().Trim()); _log.Debug("Command Output: {Output}", output.ToString().Trim());
@ -54,32 +57,32 @@ public sealed class GitRepository : IGitRepository
// Nothing to do here // Nothing to do here
} }
public async Task ForceCheckout(string branch) public async Task ForceCheckout(CancellationToken token, string branch)
{ {
await RunGitCmd("checkout", "-f", branch); await RunGitCmd(token, "checkout", "-f", branch);
} }
public async Task Fetch(string remote = "origin") public async Task Fetch(CancellationToken token, string remote = "origin")
{ {
await RunGitCmd("fetch", remote); await RunGitCmd(token, "fetch", remote);
} }
public async Task Status() public async Task Status(CancellationToken token)
{ {
await RunGitCmd("status"); await RunGitCmd(token, "status");
} }
public async Task ResetHard(string toBranchOrSha1) public async Task ResetHard(CancellationToken token, string toBranchOrSha1)
{ {
await RunGitCmd("reset", "--hard", toBranchOrSha1); await RunGitCmd(token, "reset", "--hard", toBranchOrSha1);
} }
public async Task SetRemote(string name, Uri newUrl) public async Task SetRemote(CancellationToken token, string name, Uri newUrl)
{ {
await RunGitCmd("remote", "set-url", name, newUrl.ToString()); await RunGitCmd(token, "remote", "set-url", name, newUrl.ToString());
} }
public async Task Clone(Uri cloneUrl, string? branch = null, int depth = 0) public async Task Clone(CancellationToken token, Uri cloneUrl, string? branch = null, int depth = 0)
{ {
var args = new List<string> {"clone"}; var args = new List<string> {"clone"};
if (branch is not null) if (branch is not null)
@ -93,6 +96,6 @@ public sealed class GitRepository : IGitRepository
} }
args.AddRange(new[] {cloneUrl.ToString(), "."}); args.AddRange(new[] {cloneUrl.ToString(), "."});
await RunGitCmd(args); await RunGitCmd(token, args);
} }
} }

@ -7,29 +7,48 @@ public class GitRepositoryFactory : IGitRepositoryFactory
private readonly ILogger _log; private readonly ILogger _log;
private readonly IGitPath _gitPath; private readonly IGitPath _gitPath;
// A few hand-picked files that should exist in a .git directory.
private static readonly string[] ValidGitPaths =
{
".git/config",
".git/index",
".git/HEAD"
};
public GitRepositoryFactory(ILogger log, IGitPath gitPath) public GitRepositoryFactory(ILogger log, IGitPath gitPath)
{ {
_log = log; _log = log;
_gitPath = gitPath; _gitPath = gitPath;
} }
public async Task<IGitRepository> CreateAndCloneIfNeeded(Uri repoUrl, IDirectoryInfo repoPath, string branch) public async Task<IGitRepository> CreateAndCloneIfNeeded(
Uri repoUrl,
IDirectoryInfo repoPath,
string branch,
CancellationToken token)
{ {
var repo = new GitRepository(_log, _gitPath, repoPath); var repo = new GitRepository(_log, _gitPath, repoPath);
if (!repoPath.Exists) if (!repoPath.Exists)
{ {
_log.Information("Cloning '{RepoName}' repository...", repoPath.Name); _log.Information("Cloning...");
await repo.Clone(repoUrl, branch, 1); await repo.Clone(token, repoUrl, branch, 1);
} }
else else
{ {
// First check if the `.git` directory is present and intact. We used to just do a `git status` here, but
// this sometimes has a false positive if our repo directory is inside another repository.
if (ValidGitPaths.Select(repoPath.File).Any(x => !x.Exists))
{
throw new InvalidGitRepoException("A `.git` directory or its files are missing");
}
// Run just to check repository health. If unhealthy, an exception will // Run just to check repository health. If unhealthy, an exception will
// be thrown. That exception will propagate up and result in a re-clone. // be thrown. That exception will propagate up and result in a re-clone.
await repo.Status(); await repo.Status(token);
} }
await repo.SetRemote("origin", repoUrl); await repo.SetRemote(token, "origin", repoUrl);
_log.Debug("Remote 'origin' set to {Url}", repoUrl); _log.Debug("Remote 'origin' set to {Url}", repoUrl);
return repo; return repo;

@ -1,11 +1,15 @@
using System.Diagnostics.CodeAnalysis;
namespace Recyclarr.TrashLib.Repo.VersionControl; namespace Recyclarr.TrashLib.Repo.VersionControl;
[SuppressMessage("Design", "CA1068:CancellationToken parameters must come last", Justification =
"Doesn't mix well with `params` (which has to be at the end)")]
public interface IGitRepository : IDisposable public interface IGitRepository : IDisposable
{ {
Task ForceCheckout(string branch); Task ForceCheckout(CancellationToken token, string branch);
Task Fetch(string remote = "origin"); Task Fetch(CancellationToken token, string remote = "origin");
Task ResetHard(string toBranchOrSha1); Task ResetHard(CancellationToken token, string toBranchOrSha1);
Task SetRemote(string name, Uri newUrl); Task SetRemote(CancellationToken token, string name, Uri newUrl);
Task Clone(Uri cloneUrl, string? branch = null, int depth = 0); Task Clone(CancellationToken token, Uri cloneUrl, string? branch = null, int depth = 0);
Task Status(); Task Status(CancellationToken token);
} }

@ -4,5 +4,9 @@ namespace Recyclarr.TrashLib.Repo.VersionControl;
public interface IGitRepositoryFactory public interface IGitRepositoryFactory
{ {
Task<IGitRepository> CreateAndCloneIfNeeded(Uri repoUrl, IDirectoryInfo repoPath, string branch); Task<IGitRepository> CreateAndCloneIfNeeded(
Uri repoUrl,
IDirectoryInfo repoPath,
string branch,
CancellationToken token);
} }

@ -50,7 +50,6 @@ public class TrashLibAutofacModule : Module
private static void CommonRegistrations(ContainerBuilder builder) private static void CommonRegistrations(ContainerBuilder builder)
{ {
builder.RegisterType<DefaultEnvironment>().As<IEnvironment>(); builder.RegisterType<DefaultEnvironment>().As<IEnvironment>();
builder.RegisterType<FileUtilities>().As<IFileUtilities>();
builder.RegisterType<RuntimeValidationService>().As<IRuntimeValidationService>(); builder.RegisterType<RuntimeValidationService>().As<IRuntimeValidationService>();
} }

@ -1,10 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using Autofac;
using Recyclarr.Cli.Console.Commands; using Recyclarr.Cli.Console.Commands;
using Recyclarr.Cli.TestLibrary; using Recyclarr.Cli.TestLibrary;
using Recyclarr.TestLibrary.Autofac;
using Recyclarr.TrashLib.Config.Listers;
using Recyclarr.TrashLib.Repo; using Recyclarr.TrashLib.Repo;
namespace Recyclarr.Cli.Tests.Console.Commands; namespace Recyclarr.Cli.Tests.Console.Commands;
@ -13,52 +8,23 @@ namespace Recyclarr.Cli.Tests.Console.Commands;
[Parallelizable(ParallelScope.All)] [Parallelizable(ParallelScope.All)]
public class ConfigCommandsIntegrationTest : CliIntegrationFixture public class ConfigCommandsIntegrationTest : CliIntegrationFixture
{ {
protected override void RegisterTypes(ContainerBuilder builder) [Test, AutoMockData]
public async Task Repo_update_is_called_on_config_list(
[Frozen] IMultiRepoUpdater updater,
ConfigListCommand sut)
{ {
base.RegisterTypes(builder); await sut.ExecuteAsync(default!, new ConfigListCommand.CliSettings());
builder.RegisterMockFor<IConfigTemplatesRepo>(x =>
{
x.Path.Returns(_ => Fs.CurrentDirectory());
});
}
[Test]
[SuppressMessage("Usage", "NS5000:Received check.", Justification =
"See: https://github.com/nsubstitute/NSubstitute.Analyzers/issues/211")]
public async Task Repo_update_is_called_on_config_list()
{
var repo = Resolve<IConfigTemplatesRepo>();
// Create this to make ConfigTemplateGuideService happy. It tries to parse this file, but
// it won't exist because we don't operate with real Git objects (so a clone never happens).
Fs.AddFile(repo.Path.File("templates.json"), new MockFileData("{}"));
var sut = Resolve<ConfigListCommand>(); await updater.ReceivedWithAnyArgs().UpdateAllRepositories(default);
await sut.ExecuteAsync(default!, new ConfigListCommand.CliSettings
{
ListCategory = ConfigCategory.Templates
});
await repo.Received().Update();
} }
[Test] [Test, AutoMockData]
[SuppressMessage("Usage", "NS5000:Received check.", Justification = public async Task Repo_update_is_called_on_config_create(
"See: https://github.com/nsubstitute/NSubstitute.Analyzers/issues/211")] [Frozen] IMultiRepoUpdater updater,
public async Task Repo_update_is_called_on_config_create() ConfigCreateCommand sut)
{ {
var repo = Resolve<IConfigTemplatesRepo>(); await sut.ExecuteAsync(default!, new ConfigCreateCommand.CliSettings());
// Create this to make ConfigTemplateGuideService happy. It tries to parse this file, but
// it won't exist because we don't operate with real Git objects (so a clone never happens).
Fs.AddFile(repo.Path.File("templates.json"), new MockFileData("{}"));
var sut = Resolve<ConfigCreateCommand>();
await sut.ExecuteAsync(default!, new ConfigCreateCommand.CliSettings
{
TemplatesOption = new[] {"some-template"}
});
await repo.Received().Update(); await updater.ReceivedWithAnyArgs().UpdateAllRepositories(default);
} }
} }

@ -0,0 +1,40 @@
using Recyclarr.Cli.Console.Commands;
using Recyclarr.Cli.TestLibrary;
using Recyclarr.TrashLib.Repo;
namespace Recyclarr.Cli.Tests.Console.Commands;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ListCommandsIntegrationTest : CliIntegrationFixture
{
[Test, AutoMockData]
public async Task Repo_update_is_called_on_list_custom_formats(
[Frozen] IMultiRepoUpdater updater,
ListCustomFormatsCommand sut)
{
await sut.ExecuteAsync(default!, new ListCustomFormatsCommand.CliSettings());
await updater.ReceivedWithAnyArgs().UpdateAllRepositories(default);
}
[Test, AutoMockData]
public async Task Repo_update_is_called_on_list_qualities(
[Frozen] IMultiRepoUpdater updater,
ListQualitiesCommand sut)
{
await sut.ExecuteAsync(default!, new ListQualitiesCommand.CliSettings());
await updater.ReceivedWithAnyArgs().UpdateAllRepositories(default);
}
[Test, AutoMockData]
public async Task Repo_update_is_called_on_list_release_profiles(
[Frozen] IMultiRepoUpdater updater,
ListReleaseProfilesCommand sut)
{
await sut.ExecuteAsync(default!, new ListReleaseProfilesCommand.CliSettings());
await updater.ReceivedWithAnyArgs().UpdateAllRepositories(default);
}
}

@ -0,0 +1,64 @@
using System.IO.Abstractions;
using Recyclarr.Cli.Console.Settings;
using Recyclarr.Cli.Processors.Config;
using Recyclarr.Cli.TestLibrary;
using Recyclarr.TrashLib.Repo;
namespace Recyclarr.Cli.Tests.Processors.Config;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class TemplateConfigCreatorIntegrationTest : CliIntegrationFixture
{
[Test]
public void Template_id_matching_works()
{
const string templatesJson =
"""
{
"radarr": [
{
"template": "template-file1.yml",
"id": "template1"
}
],
"sonarr": [
{
"template": "template-file2.yml",
"id": "template2"
},
{
"template": "template-file3.yml",
"id": "template3"
}
]
}
""";
var repo = Resolve<IConfigTemplatesRepo>();
Fs.AddFile(repo.Path.File("templates.json"), new MockFileData(templatesJson));
Fs.AddEmptyFile(repo.Path.File("template-file1.yml"));
Fs.AddEmptyFile(repo.Path.File("template-file2.yml"));
// This one shouldn't show up in the result because the user didn't ask for it
Fs.AddEmptyFile(repo.Path.File("template-file3.yml"));
var settings = Substitute.For<ICreateConfigSettings>();
settings.Templates.Returns(new[]
{
"template1",
"template2",
// This one shouldn't show up in the results because:
// User specified it, but no template file exists for it.
"template4"
});
var sut = Resolve<TemplateConfigCreator>();
sut.Create(settings);
Fs.AllFiles.Should().Contain(new[]
{
Paths.ConfigsDirectory.File("template-file1.yml").FullName,
Paths.ConfigsDirectory.File("template-file2.yml").FullName
});
}
}

@ -32,7 +32,7 @@ public class TemplateConfigCreatorTest : CliIntegrationFixture
} }
[Test, AutoMockData] [Test, AutoMockData]
public async Task No_replace_when_file_exists_and_not_forced( public void No_replace_when_file_exists_and_not_forced(
[Frozen] IConfigTemplateGuideService templates, [Frozen] IConfigTemplateGuideService templates,
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen] IAppPaths paths, [Frozen] IAppPaths paths,
@ -48,13 +48,13 @@ public class TemplateConfigCreatorTest : CliIntegrationFixture
settings.Force.Returns(false); settings.Force.Returns(false);
settings.Path.Returns(templateFile.FullName); settings.Path.Returns(templateFile.FullName);
await sut.Create(settings); sut.Create(settings);
fs.GetFile(destFile).TextContents.Should().Be("b"); fs.GetFile(destFile).TextContents.Should().Be("b");
} }
[Test, AutoMockData] [Test, AutoMockData]
public async Task No_throw_when_file_exists_and_forced( public void No_throw_when_file_exists_and_forced(
[Frozen] IConfigTemplateGuideService templates, [Frozen] IConfigTemplateGuideService templates,
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen] IAppPaths paths, [Frozen] IAppPaths paths,
@ -70,6 +70,6 @@ public class TemplateConfigCreatorTest : CliIntegrationFixture
var act = () => sut.Create(settings); var act = () => sut.Create(settings);
await act.Should().NotThrowAsync(); act.Should().NotThrow();
} }
} }

@ -13,14 +13,14 @@ namespace Recyclarr.Cli.Tests.Processors;
public class ConfigCreationProcessorIntegrationTest : CliIntegrationFixture public class ConfigCreationProcessorIntegrationTest : CliIntegrationFixture
{ {
[Test] [Test]
public async Task Config_file_created_when_using_default_path() public void Config_file_created_when_using_default_path()
{ {
var repo = Resolve<IConfigTemplatesRepo>(); var repo = Resolve<IConfigTemplatesRepo>();
Fs.AddFile(repo.Path.File("templates.json"), new MockFileData("{}")); Fs.AddFile(repo.Path.File("templates.json"), new MockFileData("{}"));
var sut = Resolve<ConfigCreationProcessor>(); var sut = Resolve<ConfigCreationProcessor>();
await sut.Process(new ConfigCreateCommand.CliSettings sut.Process(new ConfigCreateCommand.CliSettings
{ {
Path = null Path = null
}); });
@ -31,7 +31,7 @@ public class ConfigCreationProcessorIntegrationTest : CliIntegrationFixture
} }
[Test] [Test]
public async Task Config_file_created_when_using_user_specified_path() public void Config_file_created_when_using_user_specified_path()
{ {
var sut = Resolve<ConfigCreationProcessor>(); var sut = Resolve<ConfigCreationProcessor>();
@ -44,7 +44,7 @@ public class ConfigCreationProcessorIntegrationTest : CliIntegrationFixture
.FullName .FullName
}; };
await sut.Process(settings); sut.Process(settings);
var file = Fs.GetFile(settings.Path); var file = Fs.GetFile(settings.Path);
file.Should().NotBeNull(); file.Should().NotBeNull();
@ -52,7 +52,7 @@ public class ConfigCreationProcessorIntegrationTest : CliIntegrationFixture
} }
[Test] [Test]
public async Task Should_throw_if_file_already_exists() public void Should_throw_if_file_already_exists()
{ {
var sut = Resolve<ConfigCreationProcessor>(); var sut = Resolve<ConfigCreationProcessor>();
@ -65,11 +65,11 @@ public class ConfigCreationProcessorIntegrationTest : CliIntegrationFixture
var act = () => sut.Process(settings); var act = () => sut.Process(settings);
await act.Should().ThrowAsync<FileExistsException>(); act.Should().Throw<FileExistsException>();
} }
[Test] [Test]
public async Task Template_id_matching_works() public void Template_id_matching_works()
{ {
const string templatesJson = const string templatesJson =
""" """
@ -111,7 +111,7 @@ public class ConfigCreationProcessorIntegrationTest : CliIntegrationFixture
}); });
var sut = Resolve<ConfigCreationProcessor>(); var sut = Resolve<ConfigCreationProcessor>();
await sut.Process(settings); sut.Process(settings);
Fs.AllFiles.Should().Contain(new[] Fs.AllFiles.Should().Contain(new[]
{ {

@ -22,13 +22,13 @@ public class ConfigCreationProcessorTest
} }
[Test, AutoMockData] [Test, AutoMockData]
public async Task Throw_when_no_config_creators_can_handle( public void Throw_when_no_config_creators_can_handle(
[CustomizeWith(typeof(EmptyOrderedEnumerable))] ConfigCreationProcessor sut) [CustomizeWith(typeof(EmptyOrderedEnumerable))] ConfigCreationProcessor sut)
{ {
var settings = new ConfigCreateCommand.CliSettings(); var settings = new ConfigCreateCommand.CliSettings();
var act = () => sut.Process(settings); var act = () => sut.Process(settings);
await act.Should().ThrowAsync<FatalException>(); act.Should().Throw<FatalException>();
} }
} }

@ -10,7 +10,7 @@ public class ConfigListProcessorTest
{ {
[Test] [Test]
[InlineAutoMockData(ConfigCategory.Templates)] [InlineAutoMockData(ConfigCategory.Templates)]
public async Task List_templates_invokes_correct_lister( public void List_templates_invokes_correct_lister(
ConfigCategory category, ConfigCategory category,
[Frozen(Matching.ImplementedInterfaces)] StubAutofacIndex<ConfigCategory, IConfigLister> configListers, [Frozen(Matching.ImplementedInterfaces)] StubAutofacIndex<ConfigCategory, IConfigLister> configListers,
IConfigLister lister, IConfigLister lister,
@ -18,8 +18,8 @@ public class ConfigListProcessorTest
{ {
configListers.Add(category, lister); configListers.Add(category, lister);
await sut.Process(category); sut.Process(category);
await lister.Received().List(); lister.Received().List();
} }
} }

@ -12,7 +12,7 @@ namespace Recyclarr.TrashLib.Tests.Config.Listers;
public class ConfigTemplateListerTest : TrashLibIntegrationFixture public class ConfigTemplateListerTest : TrashLibIntegrationFixture
{ {
[Test, AutoMockData] [Test, AutoMockData]
public async Task Hidden_templates_are_not_rendered( public void Hidden_templates_are_not_rendered(
IFileInfo stubFile, IFileInfo stubFile,
[Frozen(Matching.ImplementedInterfaces)] TestConsole console, [Frozen(Matching.ImplementedInterfaces)] TestConsole console,
[Frozen] IConfigTemplateGuideService guideService, [Frozen] IConfigTemplateGuideService guideService,
@ -26,7 +26,7 @@ public class ConfigTemplateListerTest : TrashLibIntegrationFixture
new TemplatePath {Id = "s2", TemplateFile = stubFile, Service = SupportedServices.Sonarr, Hidden = true} new TemplatePath {Id = "s2", TemplateFile = stubFile, Service = SupportedServices.Sonarr, Hidden = true}
}); });
await sut.List(); sut.List();
console.Output.Should().NotContain("s2"); console.Output.Should().NotContain("s2");
} }

@ -17,11 +17,11 @@ public class ConfigTemplateGuideServiceTest : TrashLibIntegrationFixture
{ {
var act = () => _ = sut.LoadTemplateData(); var act = () => _ = sut.LoadTemplateData();
act.Should().ThrowAsync<InvalidDataException>().WithMessage("Recyclarr*templates*"); act.Should().Throw<InvalidDataException>().WithMessage("Recyclarr*templates*");
} }
[Test] [Test]
public async Task Normal_behavior() public void Normal_behavior()
{ {
var repo = Resolve<IConfigTemplatesRepo>(); var repo = Resolve<IConfigTemplatesRepo>();
var templateDir = repo.Path; var templateDir = repo.Path;
@ -43,7 +43,7 @@ public class ConfigTemplateGuideServiceTest : TrashLibIntegrationFixture
var sut = Resolve<ConfigTemplateGuideService>(); var sut = Resolve<ConfigTemplateGuideService>();
var data = await sut.LoadTemplateData(); var data = sut.LoadTemplateData();
data.Should().BeEquivalentTo(expectedPaths, o => o.Excluding(x => x.TemplateFile)); data.Should().BeEquivalentTo(expectedPaths, o => o.Excluding(x => x.TemplateFile));
data.Select(x => x.TemplateFile.FullName) data.Select(x => x.TemplateFile.FullName)
.Should().BeEquivalentTo(expectedPaths.Select(x => x.TemplateFile.FullName)); .Should().BeEquivalentTo(expectedPaths.Select(x => x.TemplateFile.FullName));

Loading…
Cancel
Save