From 92c0bee83bd9b265a4dd4b147d13ff1e4cfe4da8 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:54:48 -0600 Subject: [PATCH 01/23] feat: begin working on console app --- .../AltGen.Console.Tests.csproj | 25 +++++++++++++++++ src/AltGen.Console.Tests/UnitTest1.cs | 10 +++++++ src/AltGen.Console/AltGen.Console.csproj | 16 +++++++++++ src/AltGen.Console/Common/TypeRegistrar.cs | 28 +++++++++++++++++++ src/AltGen.Console/Common/TypeResolver.cs | 24 ++++++++++++++++ src/AltGen.Console/Program.cs | 14 ++++++++++ src/AltGen.Console/Usings.cs | 4 +++ src/AltGen.sln | 12 ++++++++ 8 files changed, 133 insertions(+) create mode 100644 src/AltGen.Console.Tests/AltGen.Console.Tests.csproj create mode 100644 src/AltGen.Console.Tests/UnitTest1.cs create mode 100644 src/AltGen.Console/AltGen.Console.csproj create mode 100644 src/AltGen.Console/Common/TypeRegistrar.cs create mode 100644 src/AltGen.Console/Common/TypeResolver.cs create mode 100644 src/AltGen.Console/Program.cs create mode 100644 src/AltGen.Console/Usings.cs diff --git a/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj b/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj new file mode 100644 index 0000000..ffec1a6 --- /dev/null +++ b/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/src/AltGen.Console.Tests/UnitTest1.cs b/src/AltGen.Console.Tests/UnitTest1.cs new file mode 100644 index 0000000..39b4247 --- /dev/null +++ b/src/AltGen.Console.Tests/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace AltGen.Console.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + + } +} diff --git a/src/AltGen.Console/AltGen.Console.csproj b/src/AltGen.Console/AltGen.Console.csproj new file mode 100644 index 0000000..b8472d7 --- /dev/null +++ b/src/AltGen.Console/AltGen.Console.csproj @@ -0,0 +1,16 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + diff --git a/src/AltGen.Console/Common/TypeRegistrar.cs b/src/AltGen.Console/Common/TypeRegistrar.cs new file mode 100644 index 0000000..010dbd7 --- /dev/null +++ b/src/AltGen.Console/Common/TypeRegistrar.cs @@ -0,0 +1,28 @@ +namespace AltGen.Console.Common; + +sealed class TypeRegistrar(IHostBuilder builder) : ITypeRegistrar +{ + readonly IHostBuilder _builder = builder; + + public ITypeResolver Build() + { + return new TypeResolver(_builder.Build()); + } + + public void Register(Type service, Type implementation) + { + _builder.ConfigureServices((_, services) => services.AddSingleton(service, implementation)); + } + + public void RegisterInstance(Type service, object implementation) + { + _builder.ConfigureServices((_, services) => services.AddSingleton(service, implementation)); + } + + public void RegisterLazy(Type service, Func func) + { + ArgumentNullException.ThrowIfNull(func); + + _builder.ConfigureServices((_, services) => services.AddSingleton(service, _ => func())); + } +} \ No newline at end of file diff --git a/src/AltGen.Console/Common/TypeResolver.cs b/src/AltGen.Console/Common/TypeResolver.cs new file mode 100644 index 0000000..61494de --- /dev/null +++ b/src/AltGen.Console/Common/TypeResolver.cs @@ -0,0 +1,24 @@ +namespace AltGen.Console.Common; + +sealed class TypeResolver(IHost host) : ITypeResolver, IDisposable +{ + readonly IHost _host = host ?? throw new ArgumentNullException(nameof(host)); + + public object? Resolve(Type? type) + { + if (type is null) + { + return null; + } + + return _host.Services.GetService(type); + } + + public void Dispose() + { + if (_host is IDisposable disposable) + { + disposable.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/AltGen.Console/Program.cs b/src/AltGen.Console/Program.cs new file mode 100644 index 0000000..7b33f13 --- /dev/null +++ b/src/AltGen.Console/Program.cs @@ -0,0 +1,14 @@ +// TODO: We want to be able +// to provide a path to an image +// read the image +// and then post the image to our API +// get the response and display the alt text + +using Microsoft.Extensions.Hosting; + +await Host.CreateDefaultBuilder(args) + .ConfigureServices(static (_, services) => { }) + .Build() // TODO: Need special extension for integrating host with Spectre.Console + .RunAsync(); + +Console.WriteLine("Hello, World!"); \ No newline at end of file diff --git a/src/AltGen.Console/Usings.cs b/src/AltGen.Console/Usings.cs new file mode 100644 index 0000000..fdd2bfc --- /dev/null +++ b/src/AltGen.Console/Usings.cs @@ -0,0 +1,4 @@ +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; + +global using Spectre.Console.Cli; diff --git a/src/AltGen.sln b/src/AltGen.sln index d9e7d5d..36f70b1 100644 --- a/src/AltGen.sln +++ b/src/AltGen.sln @@ -7,6 +7,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AltGen.API", "AltGen.API\Al EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AltGen.API.Tests", "AltGen.API.Tests\AltGen.API.Tests.csproj", "{FC95942B-3579-4F83-A447-A96D8EEF8E45}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AltGen.Console", "AltGen.Console\AltGen.Console.csproj", "{83DA683F-A2C5-4AB7-A6FD-294DFBC08D87}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AltGen.Console.Tests", "AltGen.Console.Tests\AltGen.Console.Tests.csproj", "{F7DC6A70-907B-448A-A69D-4E2E80FA3FD3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -24,5 +28,13 @@ Global {FC95942B-3579-4F83-A447-A96D8EEF8E45}.Debug|Any CPU.Build.0 = Debug|Any CPU {FC95942B-3579-4F83-A447-A96D8EEF8E45}.Release|Any CPU.ActiveCfg = Release|Any CPU {FC95942B-3579-4F83-A447-A96D8EEF8E45}.Release|Any CPU.Build.0 = Release|Any CPU + {83DA683F-A2C5-4AB7-A6FD-294DFBC08D87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {83DA683F-A2C5-4AB7-A6FD-294DFBC08D87}.Debug|Any CPU.Build.0 = Debug|Any CPU + {83DA683F-A2C5-4AB7-A6FD-294DFBC08D87}.Release|Any CPU.ActiveCfg = Release|Any CPU + {83DA683F-A2C5-4AB7-A6FD-294DFBC08D87}.Release|Any CPU.Build.0 = Release|Any CPU + {F7DC6A70-907B-448A-A69D-4E2E80FA3FD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F7DC6A70-907B-448A-A69D-4E2E80FA3FD3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F7DC6A70-907B-448A-A69D-4E2E80FA3FD3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F7DC6A70-907B-448A-A69D-4E2E80FA3FD3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal From 4672644127670c19f87b25ee8ad1faf3697fdb05 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 10 Feb 2025 15:31:25 -0600 Subject: [PATCH 02/23] feat: more work on getting console client sorted --- src/AltGen.Console/AltGen.Console.csproj | 3 + .../Common/HostBuilderExtensions.cs | 11 ++ src/AltGen.Console/Program.cs | 158 +++++++++++++++++- 3 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 src/AltGen.Console/Common/HostBuilderExtensions.cs diff --git a/src/AltGen.Console/AltGen.Console.csproj b/src/AltGen.Console/AltGen.Console.csproj index b8472d7..d51b937 100644 --- a/src/AltGen.Console/AltGen.Console.csproj +++ b/src/AltGen.Console/AltGen.Console.csproj @@ -9,8 +9,11 @@ + + + diff --git a/src/AltGen.Console/Common/HostBuilderExtensions.cs b/src/AltGen.Console/Common/HostBuilderExtensions.cs new file mode 100644 index 0000000..2447aa0 --- /dev/null +++ b/src/AltGen.Console/Common/HostBuilderExtensions.cs @@ -0,0 +1,11 @@ +namespace AltGen.Console.Common; + +static class HostBuilderExtensions +{ + public static CommandApp BuildApp(this IHostBuilder builder) + { + var registrar = new TypeRegistrar(builder); + var app = new CommandApp(registrar); + return app; + } +} \ No newline at end of file diff --git a/src/AltGen.Console/Program.cs b/src/AltGen.Console/Program.cs index 7b33f13..e31f069 100644 --- a/src/AltGen.Console/Program.cs +++ b/src/AltGen.Console/Program.cs @@ -4,11 +4,159 @@ // and then post the image to our API // get the response and display the alt text -using Microsoft.Extensions.Hosting; +using System.ComponentModel; +using System.IO.Abstractions; +using System.Net.Http.Headers; + +using AltGen.Console.Common; + +using Spectre.Console; await Host.CreateDefaultBuilder(args) - .ConfigureServices(static (_, services) => { }) - .Build() // TODO: Need special extension for integrating host with Spectre.Console - .RunAsync(); + .ConfigureServices(static (_, services) => + { + services.AddSingleton(); + services.AddSingleton(AnsiConsole.Console); + services.AddHttpClient() + .AddStandardResilienceHandler(); + }) + .BuildApp() + .RunAsync(args); -Console.WriteLine("Hello, World!"); \ No newline at end of file +sealed class GenerateCommand( + IAnsiConsole console, + IFileSystem fileSystem, + IAltGenService altGenService +) : AsyncCommand +{ + + readonly IAnsiConsole _console = console; + readonly IFileSystem _fileSystem = fileSystem; + readonly IAltGenService _altGenService = altGenService; + + public class Settings(IFileSystem fileSystem) : CommandSettings + { + readonly Dictionary _imageTypes = new() + { + [".jpeg"] = "image/jpeg", + [".jpg"] = "image/jpeg", + [".png"] = "image/png" + }; + + readonly List _providers = [ + "gemini", + ]; + + readonly IFileSystem _fileSystem = fileSystem; + + [CommandArgument(1, "")] + [Description("The provider to use for generating alt text.")] + public string Provider { get; init; } = string.Empty; + + [CommandArgument(2, "")] + [Description("The key for the provider.")] + public string Key { get; init; } = string.Empty; + + [CommandArgument(3, "")] + [Description("The path to the image to generate alt text for.")] + public string Path { get; init; } = string.Empty; + + public string ContentType => _imageTypes[_fileSystem.Path.GetExtension(Path)]; + + public override ValidationResult Validate() + { + if (_providers.Contains(Provider) is false) + { + return ValidationResult.Error($"The provider '{Provider}' is not supported."); + } + + var pathExists = _fileSystem.File.Exists(Path); + + if (pathExists is false) + { + return ValidationResult.Error($"The path '{Path}' does not exist."); + } + + var extension = _fileSystem.Path.GetExtension(Path); + + if (_imageTypes.ContainsKey(extension) is false) + { + return ValidationResult.Error($"The file '{Path}' is not a valid image file."); + } + + return ValidationResult.Success(); + } + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + var fileName = _fileSystem.Path.GetFileName(settings.Path); + var image = await _fileSystem.File.ReadAllBytesAsync(settings.Path); + + var altText = await _altGenService.GenerateAltTextAsync( + settings.Provider, + settings.Key, + fileName, + image, + settings.ContentType + ); + + _console.MarkupLine($"[bold]Alt Text:[/] {altText}"); + + return 0; + } +} + +interface IAltGenService +{ + Task GenerateAltTextAsync( + string provider, + string key, + string fileName, + byte[] image, + string contentType + ); +} + +sealed class AltGenService(HttpClient client) : IAltGenService +{ + readonly HttpClient _client = client; + + public async Task GenerateAltTextAsync( + string provider, + string key, + string fileName, + byte[] image, + string contentType + ) + { + var byteContent = new ByteArrayContent(image); + byteContent.Headers.ContentType = new MediaTypeHeaderValue(contentType); + + // TODO: Need to have proper URL + // have constants generated at build time + // that point to proper API URL + var request = new HttpRequestMessage(HttpMethod.Post, "altgen") + { + Content = new MultipartFormDataContent + { + { new StringContent(provider), "provider" }, + { new StringContent(key), "providerKey" }, + { new ByteArrayContent(image), "file", fileName } + } + }; + + var response = await _client.SendAsync(request); + + if (response.IsSuccessStatusCode is false) + { + // TODO: Use accurate exception + throw new InvalidDataException("Failed to generate alt text."); + } + + // TODO: Deserialize the response + var altText = await response.Content.ReadAsStringAsync(); + + return altText; + } +} \ No newline at end of file From ac9bfe1ac40fab609cd381cdf87a02a5fc34f4a6 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 12 Feb 2025 16:10:12 -0600 Subject: [PATCH 03/23] chore: add files for debugging --- .vscode/launch.json | 49 +++++++++++++++++++++++++++++++++++++++++++++ .vscode/tasks.json | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5230e77 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,49 @@ +{ + "version": "0.2.0", + "inputs": [ + { + "id": "arguments", + "description": "The arguments to pass to the program", + "type": "promptString", + "default": "" + } + ], + "configurations": [ + { + "name": "AltGen.API", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-api", + "program": "${workspaceFolder}/src/AltGen.API/bin/Debug/net9.0/AltGen.API.dll", + "args": [], + "cwd": "${workspaceFolder}/src/AltGen.API", + "stopAtEntry": false, + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + }, + { + "name": "AltGen.Console", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/src/AltGen.Console/bin/Debug/net9.0/AltGen.Console.dll", + "args": "${input:arguments}", + "cwd": "${workspaceFolder}/src/AltGen.Console", + "console": "integratedTerminal", + "stopAtEntry": false + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..ab0a4a1 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/AltGen.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/src/AltGen.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/src/AltGen.sln" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file From 8e0a6933f795e07632f174cf2d292ecbcca6ac58 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 12 Feb 2025 16:10:24 -0600 Subject: [PATCH 04/23] fix: compare lowercase values --- src/AltGen.API/Generate/Providers/LLMProvider.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AltGen.API/Generate/Providers/LLMProvider.cs b/src/AltGen.API/Generate/Providers/LLMProvider.cs index 19b2270..71a765c 100644 --- a/src/AltGen.API/Generate/Providers/LLMProvider.cs +++ b/src/AltGen.API/Generate/Providers/LLMProvider.cs @@ -2,10 +2,10 @@ namespace AltGen.API.Generate.Providers; static class LLMProvider { - public const string Gemini = "Gemini"; + public const string Gemini = "gemini"; public static bool IsValid(string provider) { - return provider is Gemini; + return provider.ToLowerInvariant() is Gemini; } } \ No newline at end of file From ec9651e5e9eb65296c1503ee1a3bc55b1bbd32ee Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 12 Feb 2025 16:10:49 -0600 Subject: [PATCH 05/23] feat: implement generate command in console app --- .../AltGen.Console.Tests.csproj | 2 + .../Unit/AltGenServiceTests.cs | 87 ++++++++++ src/AltGen.Console.Tests/UnitTest1.cs | 10 -- src/AltGen.Console.Tests/Usings.cs | 3 + src/AltGen.Console/AltGen.Console.csproj | 30 ++++ src/AltGen.Console/Generate/AltGenService.cs | 44 +++++ .../Generate/GenerateAltTextRequest.cs | 9 + .../Generate/GenerateCommand.cs | 88 ++++++++++ src/AltGen.Console/Generate/IAltGenService.cs | 4 + src/AltGen.Console/Generated/Constants.cs | 6 + src/AltGen.Console/Program.cs | 163 ++---------------- src/AltGen.Console/Usings.cs | 11 ++ 12 files changed, 296 insertions(+), 161 deletions(-) create mode 100644 src/AltGen.Console.Tests/Unit/AltGenServiceTests.cs delete mode 100644 src/AltGen.Console.Tests/UnitTest1.cs create mode 100644 src/AltGen.Console.Tests/Usings.cs create mode 100644 src/AltGen.Console/Generate/AltGenService.cs create mode 100644 src/AltGen.Console/Generate/GenerateAltTextRequest.cs create mode 100644 src/AltGen.Console/Generate/GenerateCommand.cs create mode 100644 src/AltGen.Console/Generate/IAltGenService.cs create mode 100644 src/AltGen.Console/Generated/Constants.cs diff --git a/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj b/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj index ffec1a6..7995a27 100644 --- a/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj +++ b/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj @@ -12,6 +12,8 @@ + + diff --git a/src/AltGen.Console.Tests/Unit/AltGenServiceTests.cs b/src/AltGen.Console.Tests/Unit/AltGenServiceTests.cs new file mode 100644 index 0000000..bd700f4 --- /dev/null +++ b/src/AltGen.Console.Tests/Unit/AltGenServiceTests.cs @@ -0,0 +1,87 @@ +using System.Net; + +using FluentAssertions; +namespace AltGen.Console.Tests.Unit; + +public class AltGenServiceTests : IDisposable +{ + readonly MockHttpMessageHandler _mockHttpMessageHandler = new(); + readonly AltGenService _altGenService; + + public AltGenServiceTests() + { + _altGenService = new AltGenService(_mockHttpMessageHandler.ToHttpClient()); + } + + [Fact] + public async Task GenerateAltTextAsync_WhenCalledAndRequestSucceeds_ItShouldReturnAltText() + { + var req = new GenerateAltTextRequest( + "provider", + "key", + "file.jpg", + [1, 2, 3], + "image/jpeg" + ); + + var altText = "alt text"; + + _mockHttpMessageHandler + .When(HttpMethod.Post, "*/generate") + .Respond( + "application/json", + /*lang=json,strict*/ + $@"{{""AltText"":""{altText}""}}" + ); + + var result = await _altGenService.GenerateAltTextAsync(req); + + result.Should().Be(altText); + } + + [Fact] + public async Task GenerateAltTextAsync_WhenCalledAndRequestFails_ItShouldThrowException() + { + var req = new GenerateAltTextRequest( + "provider", + "key", + "file.jpg", + [1, 2, 3], + "image/jpeg" + ); + + _mockHttpMessageHandler + .When(HttpMethod.Post, "*/generate") + .Respond(HttpStatusCode.InternalServerError); + + var action = async () => await _altGenService.GenerateAltTextAsync(req); + + await action.Should().ThrowAsync(); + } + + [Fact] + public async Task GenerateAltTextAsync_WhenCalledAndResponseIsNull_ItShouldThrowException() + { + var req = new GenerateAltTextRequest( + "provider", + "key", + "file.jpg", + [1, 2, 3], + "image/jpeg" + ); + + _mockHttpMessageHandler + .When(HttpMethod.Post, "*/generate") + .Respond("application/json", "null"); + + var action = async () => await _altGenService.GenerateAltTextAsync(req); + + await action.Should().ThrowAsync(); + } + + public void Dispose() + { + _mockHttpMessageHandler.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/src/AltGen.Console.Tests/UnitTest1.cs b/src/AltGen.Console.Tests/UnitTest1.cs deleted file mode 100644 index 39b4247..0000000 --- a/src/AltGen.Console.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace AltGen.Console.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/src/AltGen.Console.Tests/Usings.cs b/src/AltGen.Console.Tests/Usings.cs new file mode 100644 index 0000000..a5fd333 --- /dev/null +++ b/src/AltGen.Console.Tests/Usings.cs @@ -0,0 +1,3 @@ +global using RichardSzalay.MockHttp; + +global using AltGen.Console.Generate; \ No newline at end of file diff --git a/src/AltGen.Console/AltGen.Console.csproj b/src/AltGen.Console/AltGen.Console.csproj index d51b937..f192040 100644 --- a/src/AltGen.Console/AltGen.Console.csproj +++ b/src/AltGen.Console/AltGen.Console.csproj @@ -16,4 +16,34 @@ + + + + + + Generated + $(GeneratedCodeDirectory)\Constants.cs + http://localhost:7297 + + + + + + + + + + + + + diff --git a/src/AltGen.Console/Generate/AltGenService.cs b/src/AltGen.Console/Generate/AltGenService.cs new file mode 100644 index 0000000..9576aee --- /dev/null +++ b/src/AltGen.Console/Generate/AltGenService.cs @@ -0,0 +1,44 @@ +namespace AltGen.Console.Generate; + +sealed class AltGenService(HttpClient client) : IAltGenService +{ + static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + readonly HttpClient _client = client; + + public async Task GenerateAltTextAsync(GenerateAltTextRequest req) + { + var byteContent = new ByteArrayContent(req.Image); + byteContent.Headers.ContentType = new MediaTypeHeaderValue(req.ContentType); + + var request = new HttpRequestMessage(HttpMethod.Post, $"{Constants.AltGenApiUri}/generate") + { + Content = new MultipartFormDataContent + { + { new StringContent(req.Provider), "provider" }, + { new StringContent(req.ProviderKey), "providerKey" }, + { new ByteArrayContent(req.Image), "file", req.FileName } + } + }; + + var response = await _client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode is false) + { + throw new AltTextException("Failed to generate alt text."); + } + + var altTextResponse = JsonSerializer.Deserialize(content, JsonOptions) ?? throw new AltTextException("Failed to deserialize response."); + + return altTextResponse.AltText; + } +} + +record AltTextResponse(string AltText); + +class AltTextException(string message) : Exception(message) +{ +} diff --git a/src/AltGen.Console/Generate/GenerateAltTextRequest.cs b/src/AltGen.Console/Generate/GenerateAltTextRequest.cs new file mode 100644 index 0000000..b1f432f --- /dev/null +++ b/src/AltGen.Console/Generate/GenerateAltTextRequest.cs @@ -0,0 +1,9 @@ +namespace AltGen.Console.Generate; + +record GenerateAltTextRequest( + string Provider, + string ProviderKey, + string FileName, + byte[] Image, + string ContentType +); diff --git a/src/AltGen.Console/Generate/GenerateCommand.cs b/src/AltGen.Console/Generate/GenerateCommand.cs new file mode 100644 index 0000000..d4b7259 --- /dev/null +++ b/src/AltGen.Console/Generate/GenerateCommand.cs @@ -0,0 +1,88 @@ +namespace AltGen.Console.Generate; + +sealed class GenerateCommand( + IAnsiConsole console, + IFileSystem fileSystem, + IAltGenService altGenService +) : AsyncCommand +{ + + readonly IAnsiConsole _console = console; + readonly IFileSystem _fileSystem = fileSystem; + readonly IAltGenService _altGenService = altGenService; + + public class Settings(IFileSystem fileSystem) : CommandSettings + { + readonly Dictionary _imageTypes = new() + { + [".jpeg"] = "image/jpeg", + [".jpg"] = "image/jpeg", + [".png"] = "image/png" + }; + + readonly List _providers = [ + "gemini", + ]; + + readonly IFileSystem _fileSystem = fileSystem; + + [CommandArgument(1, "")] + [Description("The provider to use for generating alt text.")] + public string Provider { get; init; } = string.Empty; + + [CommandArgument(2, "")] + [Description("The key for the provider.")] + public string Key { get; init; } = string.Empty; + + [CommandArgument(3, "")] + [Description("The path to the image to generate alt text for.")] + public string Path { get; init; } = string.Empty; + + public string ContentType => _imageTypes[_fileSystem.Path.GetExtension(Path)]; + + public override ValidationResult Validate() + { + // TODO: We should allow provider and key to be optional + // if they are not passed on the command line then + // we should try to resolve them from configuration + if (_providers.Contains(Provider) is false) + { + return ValidationResult.Error($"The provider '{Provider}' is not supported."); + } + + var pathExists = _fileSystem.File.Exists(Path); + + if (pathExists is false) + { + return ValidationResult.Error($"The path '{Path}' does not exist."); + } + + var extension = _fileSystem.Path.GetExtension(Path); + + if (_imageTypes.ContainsKey(extension) is false) + { + return ValidationResult.Error($"The file '{Path}' is not a valid image file."); + } + + return ValidationResult.Success(); + } + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + var fileName = _fileSystem.Path.GetFileName(settings.Path); + var image = await _fileSystem.File.ReadAllBytesAsync(settings.Path); + + var altText = await _altGenService.GenerateAltTextAsync(new( + settings.Provider, + settings.Key, + fileName, + image, + settings.ContentType + )); + + _console.MarkupLine($"[bold]{altText}[/]"); + + return 0; + } +} \ No newline at end of file diff --git a/src/AltGen.Console/Generate/IAltGenService.cs b/src/AltGen.Console/Generate/IAltGenService.cs new file mode 100644 index 0000000..1b2b14b --- /dev/null +++ b/src/AltGen.Console/Generate/IAltGenService.cs @@ -0,0 +1,4 @@ +interface IAltGenService +{ + Task GenerateAltTextAsync(GenerateAltTextRequest req); +} diff --git a/src/AltGen.Console/Generated/Constants.cs b/src/AltGen.Console/Generated/Constants.cs new file mode 100644 index 0000000..c9ed236 --- /dev/null +++ b/src/AltGen.Console/Generated/Constants.cs @@ -0,0 +1,6 @@ +namespace AltGen.Console.Generated; + +static class Constants +{ + public const string AltGenApiUri = "http://localhost:7297"; +} diff --git a/src/AltGen.Console/Program.cs b/src/AltGen.Console/Program.cs index e31f069..ac7c446 100644 --- a/src/AltGen.Console/Program.cs +++ b/src/AltGen.Console/Program.cs @@ -1,18 +1,17 @@ -// TODO: We want to be able -// to provide a path to an image -// read the image -// and then post the image to our API -// get the response and display the alt text - -using System.ComponentModel; -using System.IO.Abstractions; -using System.Net.Http.Headers; - -using AltGen.Console.Common; - -using Spectre.Console; +// TODO: Need to implement config +// command. This should allow +// the user to set default values +// for provider keys and a default +// provider. +// +// For example setting a providers key +// i.e. altgen config gemini mykey +// +// Or setting provider as default +// i.e. altgen config gemini mykey --default await Host.CreateDefaultBuilder(args) + .ConfigureLogging(static l => l.ClearProviders()) .ConfigureServices(static (_, services) => { services.AddSingleton(); @@ -22,141 +21,3 @@ await Host.CreateDefaultBuilder(args) }) .BuildApp() .RunAsync(args); - -sealed class GenerateCommand( - IAnsiConsole console, - IFileSystem fileSystem, - IAltGenService altGenService -) : AsyncCommand -{ - - readonly IAnsiConsole _console = console; - readonly IFileSystem _fileSystem = fileSystem; - readonly IAltGenService _altGenService = altGenService; - - public class Settings(IFileSystem fileSystem) : CommandSettings - { - readonly Dictionary _imageTypes = new() - { - [".jpeg"] = "image/jpeg", - [".jpg"] = "image/jpeg", - [".png"] = "image/png" - }; - - readonly List _providers = [ - "gemini", - ]; - - readonly IFileSystem _fileSystem = fileSystem; - - [CommandArgument(1, "")] - [Description("The provider to use for generating alt text.")] - public string Provider { get; init; } = string.Empty; - - [CommandArgument(2, "")] - [Description("The key for the provider.")] - public string Key { get; init; } = string.Empty; - - [CommandArgument(3, "")] - [Description("The path to the image to generate alt text for.")] - public string Path { get; init; } = string.Empty; - - public string ContentType => _imageTypes[_fileSystem.Path.GetExtension(Path)]; - - public override ValidationResult Validate() - { - if (_providers.Contains(Provider) is false) - { - return ValidationResult.Error($"The provider '{Provider}' is not supported."); - } - - var pathExists = _fileSystem.File.Exists(Path); - - if (pathExists is false) - { - return ValidationResult.Error($"The path '{Path}' does not exist."); - } - - var extension = _fileSystem.Path.GetExtension(Path); - - if (_imageTypes.ContainsKey(extension) is false) - { - return ValidationResult.Error($"The file '{Path}' is not a valid image file."); - } - - return ValidationResult.Success(); - } - } - - public override async Task ExecuteAsync(CommandContext context, Settings settings) - { - var fileName = _fileSystem.Path.GetFileName(settings.Path); - var image = await _fileSystem.File.ReadAllBytesAsync(settings.Path); - - var altText = await _altGenService.GenerateAltTextAsync( - settings.Provider, - settings.Key, - fileName, - image, - settings.ContentType - ); - - _console.MarkupLine($"[bold]Alt Text:[/] {altText}"); - - return 0; - } -} - -interface IAltGenService -{ - Task GenerateAltTextAsync( - string provider, - string key, - string fileName, - byte[] image, - string contentType - ); -} - -sealed class AltGenService(HttpClient client) : IAltGenService -{ - readonly HttpClient _client = client; - - public async Task GenerateAltTextAsync( - string provider, - string key, - string fileName, - byte[] image, - string contentType - ) - { - var byteContent = new ByteArrayContent(image); - byteContent.Headers.ContentType = new MediaTypeHeaderValue(contentType); - - // TODO: Need to have proper URL - // have constants generated at build time - // that point to proper API URL - var request = new HttpRequestMessage(HttpMethod.Post, "altgen") - { - Content = new MultipartFormDataContent - { - { new StringContent(provider), "provider" }, - { new StringContent(key), "providerKey" }, - { new ByteArrayContent(image), "file", fileName } - } - }; - - var response = await _client.SendAsync(request); - - if (response.IsSuccessStatusCode is false) - { - // TODO: Use accurate exception - throw new InvalidDataException("Failed to generate alt text."); - } - - // TODO: Deserialize the response - var altText = await response.Content.ReadAsStringAsync(); - - return altText; - } -} \ No newline at end of file diff --git a/src/AltGen.Console/Usings.cs b/src/AltGen.Console/Usings.cs index fdd2bfc..8dab214 100644 --- a/src/AltGen.Console/Usings.cs +++ b/src/AltGen.Console/Usings.cs @@ -1,4 +1,15 @@ +global using System.ComponentModel; +global using System.IO.Abstractions; +global using System.Net.Http.Headers; +global using System.Text.Json; + +global using AltGen.Console.Common; +global using AltGen.Console.Generated; +global using AltGen.Console.Generate; + global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; +global using Spectre.Console; global using Spectre.Console.Cli; From ba1d668ade8edd220ec7235fadf6df35645e3b22 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 18 Feb 2025 23:28:56 -0600 Subject: [PATCH 06/23] feat: initial config command implementation --- .../Common/HostBuilderExtensions.cs | 3 + src/AltGen.Console/Config/ConfigCommand.cs | 109 ++++++++++++++++++ src/AltGen.Console/Program.cs | 16 +-- src/AltGen.Console/Usings.cs | 5 +- 4 files changed, 117 insertions(+), 16 deletions(-) create mode 100644 src/AltGen.Console/Config/ConfigCommand.cs diff --git a/src/AltGen.Console/Common/HostBuilderExtensions.cs b/src/AltGen.Console/Common/HostBuilderExtensions.cs index 2447aa0..2418f36 100644 --- a/src/AltGen.Console/Common/HostBuilderExtensions.cs +++ b/src/AltGen.Console/Common/HostBuilderExtensions.cs @@ -6,6 +6,9 @@ static class HostBuilderExtensions { var registrar = new TypeRegistrar(builder); var app = new CommandApp(registrar); + + app.Configure(static c => c.AddCommand("config")); + return app; } } \ No newline at end of file diff --git a/src/AltGen.Console/Config/ConfigCommand.cs b/src/AltGen.Console/Config/ConfigCommand.cs new file mode 100644 index 0000000..b9e0c0a --- /dev/null +++ b/src/AltGen.Console/Config/ConfigCommand.cs @@ -0,0 +1,109 @@ + +using Microsoft.Extensions.Configuration; + +namespace AltGen.Console.Config; + +// TODO: We should refactor this so that +// we have a config add and a config remove command +// the config add will have basically the same code +// as below but the config remove will just +// accept a provider identifier and remove it + +sealed class ConfigCommand( + IAnsiConsole console, + IFileSystem fileSystem, + IConfiguration config +) : AsyncCommand +{ + static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true + }; + readonly IAnsiConsole _console = console; + readonly IFileSystem _fileSystem = fileSystem; + readonly IConfiguration _config = config; + + public class Settings(IFileSystem filesystem) : CommandSettings + { + readonly IFileSystem _fileSystem = filesystem; + + [CommandArgument(1, "")] + [Description("The provider to configure.")] + public string Provider { get; init; } = string.Empty; + + [CommandArgument(2, "")] + [Description("The key for the provider.")] + public string Key { get; init; } = string.Empty; + + [CommandOption("-d|--default")] + [Description("Set the provider as the default.")] + public bool Default { get; init; } + + public string SettingsPath => _fileSystem.Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + var settingsExist = _fileSystem.File.Exists(settings.SettingsPath); + + if (settingsExist is false) + { + var appSettings = new AppSettings([ + new(settings.Provider, settings.Key, settings.Default) + ]); + + var json = JsonSerializer.Serialize(appSettings, JsonOptions); + await _fileSystem.File.WriteAllTextAsync(settings.SettingsPath, json); + _console.MarkupLine($"[bold]{settings.Provider}[/] has been configured."); + return 0; + } + + var existingJson = await _fileSystem.File.ReadAllTextAsync(settings.SettingsPath); + var existingAppSettings = JsonSerializer.Deserialize(existingJson, JsonOptions) + ?? throw new ConfigException("Failed to deserialize settings."); + var updatedAppSettings = existingAppSettings.Update(settings); + var udpatedJson = JsonSerializer.Serialize(updatedAppSettings, JsonOptions); + await _fileSystem.File.WriteAllTextAsync(settings.SettingsPath, udpatedJson); + _console.MarkupLine($"[bold]{settings.Provider}[/] has been configured."); + return 0; + } +} + +record AppSettings(ProviderSettings[] Providers) +{ + public AppSettings Update(ConfigCommand.Settings settings) + { + var updatedProviders = Providers.Select(p => + { + if (p.Provider == settings.Provider) + { + return p with { Key = settings.Key, Default = settings.Default }; + } + + if (p.Provider != settings.Provider && settings.Default) + { + return p with { Default = false }; + } + + return p; + }); + + var provider = Providers.FirstOrDefault(p => p.Provider == settings.Provider); + + if (provider is null) + { + var newProvider = new ProviderSettings(settings.Provider, settings.Key, settings.Default); + return this with { Providers = [.. updatedProviders, newProvider] }; + } + + return this with { Providers = [.. updatedProviders] }; + } +} + +record ProviderSettings(string Provider, string Key, bool Default); + +class ConfigException(string message) : Exception(message) +{ +} \ No newline at end of file diff --git a/src/AltGen.Console/Program.cs b/src/AltGen.Console/Program.cs index ac7c446..48aaed7 100644 --- a/src/AltGen.Console/Program.cs +++ b/src/AltGen.Console/Program.cs @@ -1,16 +1,4 @@ -// TODO: Need to implement config -// command. This should allow -// the user to set default values -// for provider keys and a default -// provider. -// -// For example setting a providers key -// i.e. altgen config gemini mykey -// -// Or setting provider as default -// i.e. altgen config gemini mykey --default - -await Host.CreateDefaultBuilder(args) +await Host.CreateDefaultBuilder(args) .ConfigureLogging(static l => l.ClearProviders()) .ConfigureServices(static (_, services) => { @@ -20,4 +8,4 @@ await Host.CreateDefaultBuilder(args) .AddStandardResilienceHandler(); }) .BuildApp() - .RunAsync(args); + .RunAsync(args); \ No newline at end of file diff --git a/src/AltGen.Console/Usings.cs b/src/AltGen.Console/Usings.cs index 8dab214..0782a80 100644 --- a/src/AltGen.Console/Usings.cs +++ b/src/AltGen.Console/Usings.cs @@ -4,12 +4,13 @@ global using System.Net.Http.Headers; global using System.Text.Json; global using AltGen.Console.Common; -global using AltGen.Console.Generated; +global using AltGen.Console.Config; global using AltGen.Console.Generate; +global using AltGen.Console.Generated; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Hosting; global using Microsoft.Extensions.Logging; global using Spectre.Console; -global using Spectre.Console.Cli; +global using Spectre.Console.Cli; \ No newline at end of file From 22eb9a7c87b5883f2bb7d96a3dac2e227deba0bf Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 24 Feb 2025 22:53:55 -0600 Subject: [PATCH 07/23] chore: stub out remove command --- src/AltGen.Console/Config/RemoveConfigCommand.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/AltGen.Console/Config/RemoveConfigCommand.cs diff --git a/src/AltGen.Console/Config/RemoveConfigCommand.cs b/src/AltGen.Console/Config/RemoveConfigCommand.cs new file mode 100644 index 0000000..b645230 --- /dev/null +++ b/src/AltGen.Console/Config/RemoveConfigCommand.cs @@ -0,0 +1,16 @@ +namespace AltGen.Console.Config; + +sealed class RemoveConfigCommand() : AsyncCommand +{ + public class Settings : CommandSettings + { + [CommandArgument(1, "")] + [Description("The provider to remove.")] + public string Provider { get; init; } = string.Empty; + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + return 0; + } +} \ No newline at end of file From 5647070fd32785a6e0e17a095f6f33a1bce5a0ba Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sat, 8 Mar 2025 19:31:56 -0600 Subject: [PATCH 08/23] feat: wip on remove config command --- .../Unit/AddConfigCommandTests.cs | 6 ++ .../Unit/AppSettingsTests.cs | 5 ++ .../Unit/RemoveConfigCommandTests.cs | 5 ++ .../Common/HostBuilderExtensions.cs | 11 +++- .../{ConfigCommand.cs => AddConfigCommand.cs} | 56 ++----------------- src/AltGen.Console/Config/AppSettings.cs | 38 +++++++++++++ src/AltGen.Console/Config/ConfigException.cs | 5 ++ src/AltGen.Console/Config/ProviderSettings.cs | 3 + .../Config/RemoveConfigCommand.cs | 28 +++++++++- 9 files changed, 102 insertions(+), 55 deletions(-) create mode 100644 src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs create mode 100644 src/AltGen.Console.Tests/Unit/AppSettingsTests.cs create mode 100644 src/AltGen.Console.Tests/Unit/RemoveConfigCommandTests.cs rename src/AltGen.Console/Config/{ConfigCommand.cs => AddConfigCommand.cs} (58%) create mode 100644 src/AltGen.Console/Config/AppSettings.cs create mode 100644 src/AltGen.Console/Config/ConfigException.cs create mode 100644 src/AltGen.Console/Config/ProviderSettings.cs diff --git a/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs b/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs new file mode 100644 index 0000000..2e68060 --- /dev/null +++ b/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs @@ -0,0 +1,6 @@ +namespace AltGen.Console.Tests.Unit; + +public class AddConfigCommandTests +{ + +} \ No newline at end of file diff --git a/src/AltGen.Console.Tests/Unit/AppSettingsTests.cs b/src/AltGen.Console.Tests/Unit/AppSettingsTests.cs new file mode 100644 index 0000000..c7a67d0 --- /dev/null +++ b/src/AltGen.Console.Tests/Unit/AppSettingsTests.cs @@ -0,0 +1,5 @@ +namespace AltGen.Console.Tests.Unit; + +public class AppSettingsTests +{ +} \ No newline at end of file diff --git a/src/AltGen.Console.Tests/Unit/RemoveConfigCommandTests.cs b/src/AltGen.Console.Tests/Unit/RemoveConfigCommandTests.cs new file mode 100644 index 0000000..2a52faf --- /dev/null +++ b/src/AltGen.Console.Tests/Unit/RemoveConfigCommandTests.cs @@ -0,0 +1,5 @@ +namespace AltGen.Console.Tests.Unit; + +public class RemoveConfigCommandTests +{ +} \ No newline at end of file diff --git a/src/AltGen.Console/Common/HostBuilderExtensions.cs b/src/AltGen.Console/Common/HostBuilderExtensions.cs index 2418f36..97c686d 100644 --- a/src/AltGen.Console/Common/HostBuilderExtensions.cs +++ b/src/AltGen.Console/Common/HostBuilderExtensions.cs @@ -7,7 +7,16 @@ static class HostBuilderExtensions var registrar = new TypeRegistrar(builder); var app = new CommandApp(registrar); - app.Configure(static c => c.AddCommand("config")); + app.Configure(static c => + { + c.PropagateExceptions(); + + c.AddBranch("config", static c => + { + c.AddCommand("add"); + c.AddCommand("remove"); + }); + }); return app; } diff --git a/src/AltGen.Console/Config/ConfigCommand.cs b/src/AltGen.Console/Config/AddConfigCommand.cs similarity index 58% rename from src/AltGen.Console/Config/ConfigCommand.cs rename to src/AltGen.Console/Config/AddConfigCommand.cs index b9e0c0a..176b38c 100644 --- a/src/AltGen.Console/Config/ConfigCommand.cs +++ b/src/AltGen.Console/Config/AddConfigCommand.cs @@ -1,19 +1,9 @@ - -using Microsoft.Extensions.Configuration; - namespace AltGen.Console.Config; -// TODO: We should refactor this so that -// we have a config add and a config remove command -// the config add will have basically the same code -// as below but the config remove will just -// accept a provider identifier and remove it - -sealed class ConfigCommand( +sealed class AddConfigCommand( IAnsiConsole console, - IFileSystem fileSystem, - IConfiguration config -) : AsyncCommand + IFileSystem fileSystem +) : AsyncCommand { static readonly JsonSerializerOptions JsonOptions = new() { @@ -23,7 +13,6 @@ sealed class ConfigCommand( }; readonly IAnsiConsole _console = console; readonly IFileSystem _fileSystem = fileSystem; - readonly IConfiguration _config = config; public class Settings(IFileSystem filesystem) : CommandSettings { @@ -63,47 +52,10 @@ sealed class ConfigCommand( var existingJson = await _fileSystem.File.ReadAllTextAsync(settings.SettingsPath); var existingAppSettings = JsonSerializer.Deserialize(existingJson, JsonOptions) ?? throw new ConfigException("Failed to deserialize settings."); - var updatedAppSettings = existingAppSettings.Update(settings); + var updatedAppSettings = existingAppSettings.AddOrUpdateProvider(settings); var udpatedJson = JsonSerializer.Serialize(updatedAppSettings, JsonOptions); await _fileSystem.File.WriteAllTextAsync(settings.SettingsPath, udpatedJson); _console.MarkupLine($"[bold]{settings.Provider}[/] has been configured."); return 0; } -} - -record AppSettings(ProviderSettings[] Providers) -{ - public AppSettings Update(ConfigCommand.Settings settings) - { - var updatedProviders = Providers.Select(p => - { - if (p.Provider == settings.Provider) - { - return p with { Key = settings.Key, Default = settings.Default }; - } - - if (p.Provider != settings.Provider && settings.Default) - { - return p with { Default = false }; - } - - return p; - }); - - var provider = Providers.FirstOrDefault(p => p.Provider == settings.Provider); - - if (provider is null) - { - var newProvider = new ProviderSettings(settings.Provider, settings.Key, settings.Default); - return this with { Providers = [.. updatedProviders, newProvider] }; - } - - return this with { Providers = [.. updatedProviders] }; - } -} - -record ProviderSettings(string Provider, string Key, bool Default); - -class ConfigException(string message) : Exception(message) -{ } \ No newline at end of file diff --git a/src/AltGen.Console/Config/AppSettings.cs b/src/AltGen.Console/Config/AppSettings.cs new file mode 100644 index 0000000..9967e99 --- /dev/null +++ b/src/AltGen.Console/Config/AppSettings.cs @@ -0,0 +1,38 @@ +namespace AltGen.Console.Config; + +record AppSettings(ProviderSettings[] Providers) +{ + public AppSettings AddOrUpdateProvider(AddConfigCommand.Settings settings) + { + var updatedProviders = Providers.Select(p => + { + if (p.Provider == settings.Provider) + { + return p with { Key = settings.Key, Default = settings.Default }; + } + + if (p.Provider != settings.Provider && settings.Default) + { + return p with { Default = false }; + } + + return p; + }); + + var provider = Providers.FirstOrDefault(p => p.Provider == settings.Provider); + + if (provider is null) + { + var newProvider = new ProviderSettings(settings.Provider, settings.Key, settings.Default); + return this with { Providers = [.. updatedProviders, newProvider] }; + } + + return this with { Providers = [.. updatedProviders] }; + } + + public AppSettings RemoveProvider(RemoveConfigCommand.Settings settings) + { + var updatedProviders = Providers.Where(p => p.Provider != settings.Provider); + return this with { Providers = [.. updatedProviders] }; + } +} \ No newline at end of file diff --git a/src/AltGen.Console/Config/ConfigException.cs b/src/AltGen.Console/Config/ConfigException.cs new file mode 100644 index 0000000..418272e --- /dev/null +++ b/src/AltGen.Console/Config/ConfigException.cs @@ -0,0 +1,5 @@ +namespace AltGen.Console.Config; + +class ConfigException(string message) : Exception(message) +{ +} \ No newline at end of file diff --git a/src/AltGen.Console/Config/ProviderSettings.cs b/src/AltGen.Console/Config/ProviderSettings.cs new file mode 100644 index 0000000..acec697 --- /dev/null +++ b/src/AltGen.Console/Config/ProviderSettings.cs @@ -0,0 +1,3 @@ +namespace AltGen.Console.Config; + +record ProviderSettings(string Provider, string Key, bool Default); diff --git a/src/AltGen.Console/Config/RemoveConfigCommand.cs b/src/AltGen.Console/Config/RemoveConfigCommand.cs index b645230..47fdd8f 100644 --- a/src/AltGen.Console/Config/RemoveConfigCommand.cs +++ b/src/AltGen.Console/Config/RemoveConfigCommand.cs @@ -1,16 +1,40 @@ namespace AltGen.Console.Config; -sealed class RemoveConfigCommand() : AsyncCommand +sealed class RemoveConfigCommand( + IFileSystem fileSystem, + IAnsiConsole console +) : AsyncCommand { - public class Settings : CommandSettings + readonly IFileSystem _fileSystem = fileSystem; + readonly IAnsiConsole _console = console; + + public class Settings(IFileSystem fileSystem) : CommandSettings { + readonly IFileSystem _fileSystem = fileSystem; + [CommandArgument(1, "")] [Description("The provider to remove.")] public string Provider { get; init; } = string.Empty; + + public string SettingsPath => _fileSystem.Path.Combine(AppContext.BaseDirectory, "appsettings.json"); } public override async Task ExecuteAsync(CommandContext context, Settings settings) { + var exists = _fileSystem.Path.Exists(settings.SettingsPath); + + if (exists is false) + { + _console.MarkupLine("[bold]No existing settings found.[/]"); + return 1; + } + + var settingsJson = await _fileSystem.File.ReadAllTextAsync(settings.SettingsPath); + var appSettings = JsonSerializer.Deserialize(settingsJson) ?? throw new ConfigException("Failed to deserialize app settings."); + var updatedAppSettings = appSettings.RemoveProvider(settings); + var updatedJson = JsonSerializer.Serialize(updatedAppSettings); + await _fileSystem.File.WriteAllTextAsync(settings.SettingsPath, updatedJson); + _console.MarkupLine($"[bold]{settings.Provider}[/] has been removed."); return 0; } } \ No newline at end of file From 69a79e297d46ac445efd82465da643015ebc15e5 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 9 Mar 2025 12:49:28 -0500 Subject: [PATCH 09/23] tests: work on writing tests for console functionality --- .../AltGen.Console.Tests.csproj | 3 +- .../Unit/AddConfigCommandTests.cs | 10 +++ .../Unit/AppSettingsTests.cs | 78 +++++++++++++++++++ .../Unit/RemoveConfigCommandTests.cs | 18 +++++ src/AltGen.Console.Tests/Usings.cs | 11 ++- src/AltGen.Console/Common/JsonOptions.cs | 11 +++ src/AltGen.Console/Config/AddConfigCommand.cs | 12 +-- .../Config/RemoveConfigCommand.cs | 7 +- 8 files changed, 134 insertions(+), 16 deletions(-) create mode 100644 src/AltGen.Console/Common/JsonOptions.cs diff --git a/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj b/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj index 7995a27..94aed1c 100644 --- a/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj +++ b/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj @@ -10,10 +10,11 @@ + - + diff --git a/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs b/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs index 2e68060..dc5fb23 100644 --- a/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs +++ b/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs @@ -2,5 +2,15 @@ namespace AltGen.Console.Tests.Unit; public class AddConfigCommandTests { + [Fact] + public Task ExecuteAsync_WhenSettingsDoNotExist_ItShouldCreateSettings() + { + throw new NotImplementedException(); + } + [Fact] + public Task ExecuteAsync_WhenSettingsExist_ItShouldUpdateSettings() + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/src/AltGen.Console.Tests/Unit/AppSettingsTests.cs b/src/AltGen.Console.Tests/Unit/AppSettingsTests.cs index c7a67d0..bb5b868 100644 --- a/src/AltGen.Console.Tests/Unit/AppSettingsTests.cs +++ b/src/AltGen.Console.Tests/Unit/AppSettingsTests.cs @@ -2,4 +2,82 @@ namespace AltGen.Console.Tests.Unit; public class AppSettingsTests { + readonly Mock _fileSystemMock = new(); + + [Fact] + public void AddOrUpdateProvider_WhenProviderDoesNotExist_ItShouldAddProvider() + { + var providerSettings = new ProviderSettings("provider", "key", true); + + var commandSettings = new AddConfigCommand.Settings(_fileSystemMock.Object) + { + Provider = providerSettings.Provider, + Key = providerSettings.Key, + Default = providerSettings.Default, + }; + + var appSettings = new AppSettings([]); + + var updatedAppSettings = appSettings.AddOrUpdateProvider(commandSettings); + + updatedAppSettings.Should().BeEquivalentTo(new AppSettings([providerSettings])); + } + + [Fact] + public void AddOrUpdateProvider_WhenProviderDoesExist_ItShouldUpdateProvider() + { + var providerSettings = new ProviderSettings("provider", "key", true); + var existingProviderSettings = new ProviderSettings("provider", "key", false); + + var commandSettings = new AddConfigCommand.Settings(_fileSystemMock.Object) + { + Provider = providerSettings.Provider, + Key = providerSettings.Key, + Default = providerSettings.Default, + }; + + var appSettings = new AppSettings([existingProviderSettings]); + var updatedAppSettings = appSettings.AddOrUpdateProvider(commandSettings); + + updatedAppSettings.Should().BeEquivalentTo(new AppSettings([providerSettings])); + } + + [Fact] + public void AddOrUpdateProvider_WhenExistingProviderIsSetToDefaultAndNewDefaultGiven_ItShouldUpdateProvidersCorrectly() + { + var providerSettings = new ProviderSettings("claude", "key", true); + var existingProviderSettings = new ProviderSettings("gemini", "key", true); + + var commandSettings = new AddConfigCommand.Settings(_fileSystemMock.Object) + { + Provider = providerSettings.Provider, + Key = providerSettings.Key, + Default = providerSettings.Default, + }; + + var appSettings = new AppSettings([existingProviderSettings]); + var updatedAppSettings = appSettings.AddOrUpdateProvider(commandSettings); + + updatedAppSettings.Should().BeEquivalentTo(new AppSettings([ + providerSettings, + existingProviderSettings with { Default = false } + ])); + } + + [Fact] + public void RemoveProvider_WhenProviderExists_ItShouldRemoveProvider() + { + var existingProviderSettings = new ProviderSettings("provider", "key", true); + + var commandSettings = new RemoveConfigCommand.Settings(_fileSystemMock.Object) + { + Provider = existingProviderSettings.Provider, + }; + + var appSettings = new AppSettings([existingProviderSettings]); + + var updatedAppSettings = appSettings.RemoveProvider(commandSettings); + + updatedAppSettings.Should().BeEquivalentTo(new AppSettings([])); + } } \ No newline at end of file diff --git a/src/AltGen.Console.Tests/Unit/RemoveConfigCommandTests.cs b/src/AltGen.Console.Tests/Unit/RemoveConfigCommandTests.cs index 2a52faf..50dea56 100644 --- a/src/AltGen.Console.Tests/Unit/RemoveConfigCommandTests.cs +++ b/src/AltGen.Console.Tests/Unit/RemoveConfigCommandTests.cs @@ -2,4 +2,22 @@ namespace AltGen.Console.Tests.Unit; public class RemoveConfigCommandTests { + [Fact] + public Task ExecuteAsync_WhenSettingsDoNotExist_ItShouldThrow() + { + throw new NotImplementedException(); + } + + + [Fact] + public Task ExecuteAsync_WhenProviderDoesNotExist_ItShouldDoNothing() + { + throw new NotImplementedException(); + } + + [Fact] + public Task ExecuteAsync_WhenProviderExists_ItShouldRemoveProviderFromSettings() + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/src/AltGen.Console.Tests/Usings.cs b/src/AltGen.Console.Tests/Usings.cs index a5fd333..cf6c14c 100644 --- a/src/AltGen.Console.Tests/Usings.cs +++ b/src/AltGen.Console.Tests/Usings.cs @@ -1,3 +1,10 @@ -global using RichardSzalay.MockHttp; +global using System.IO.Abstractions; -global using AltGen.Console.Generate; \ No newline at end of file +global using AltGen.Console.Config; +global using AltGen.Console.Generate; + +global using FluentAssertions; + +global using Moq; + +global using RichardSzalay.MockHttp; diff --git a/src/AltGen.Console/Common/JsonOptions.cs b/src/AltGen.Console/Common/JsonOptions.cs new file mode 100644 index 0000000..f28d252 --- /dev/null +++ b/src/AltGen.Console/Common/JsonOptions.cs @@ -0,0 +1,11 @@ +namespace AltGen.Console.Common; + +static class JsonOptions +{ + public static JsonSerializerOptions Default { get; } = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true + }; +} \ No newline at end of file diff --git a/src/AltGen.Console/Config/AddConfigCommand.cs b/src/AltGen.Console/Config/AddConfigCommand.cs index 176b38c..597bd39 100644 --- a/src/AltGen.Console/Config/AddConfigCommand.cs +++ b/src/AltGen.Console/Config/AddConfigCommand.cs @@ -5,12 +5,6 @@ sealed class AddConfigCommand( IFileSystem fileSystem ) : AsyncCommand { - static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - WriteIndented = true - }; readonly IAnsiConsole _console = console; readonly IFileSystem _fileSystem = fileSystem; @@ -43,17 +37,17 @@ sealed class AddConfigCommand( new(settings.Provider, settings.Key, settings.Default) ]); - var json = JsonSerializer.Serialize(appSettings, JsonOptions); + var json = JsonSerializer.Serialize(appSettings, JsonOptions.Default); await _fileSystem.File.WriteAllTextAsync(settings.SettingsPath, json); _console.MarkupLine($"[bold]{settings.Provider}[/] has been configured."); return 0; } var existingJson = await _fileSystem.File.ReadAllTextAsync(settings.SettingsPath); - var existingAppSettings = JsonSerializer.Deserialize(existingJson, JsonOptions) + var existingAppSettings = JsonSerializer.Deserialize(existingJson, JsonOptions.Default) ?? throw new ConfigException("Failed to deserialize settings."); var updatedAppSettings = existingAppSettings.AddOrUpdateProvider(settings); - var udpatedJson = JsonSerializer.Serialize(updatedAppSettings, JsonOptions); + var udpatedJson = JsonSerializer.Serialize(updatedAppSettings, JsonOptions.Default); await _fileSystem.File.WriteAllTextAsync(settings.SettingsPath, udpatedJson); _console.MarkupLine($"[bold]{settings.Provider}[/] has been configured."); return 0; diff --git a/src/AltGen.Console/Config/RemoveConfigCommand.cs b/src/AltGen.Console/Config/RemoveConfigCommand.cs index 47fdd8f..3af4423 100644 --- a/src/AltGen.Console/Config/RemoveConfigCommand.cs +++ b/src/AltGen.Console/Config/RemoveConfigCommand.cs @@ -25,14 +25,13 @@ sealed class RemoveConfigCommand( if (exists is false) { - _console.MarkupLine("[bold]No existing settings found.[/]"); - return 1; + throw new ConfigException("No existing settings found."); } var settingsJson = await _fileSystem.File.ReadAllTextAsync(settings.SettingsPath); - var appSettings = JsonSerializer.Deserialize(settingsJson) ?? throw new ConfigException("Failed to deserialize app settings."); + var appSettings = JsonSerializer.Deserialize(settingsJson, JsonOptions.Default) ?? throw new ConfigException("Failed to deserialize app settings."); var updatedAppSettings = appSettings.RemoveProvider(settings); - var updatedJson = JsonSerializer.Serialize(updatedAppSettings); + var updatedJson = JsonSerializer.Serialize(updatedAppSettings, JsonOptions.Default); await _fileSystem.File.WriteAllTextAsync(settings.SettingsPath, updatedJson); _console.MarkupLine($"[bold]{settings.Provider}[/] has been removed."); return 0; From 5c2059cfa947abab415906ed94c4340a7d5cd971 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 10 Mar 2025 20:48:19 -0500 Subject: [PATCH 10/23] tests: work on writing tests for console functionality --- .../AltGen.Console.Tests.csproj | 1 + .../Unit/AddConfigCommandTests.cs | 76 +++++++++++++++++-- src/AltGen.Console/Config/AppSettings.cs | 9 ++- 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj b/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj index 94aed1c..401e66c 100644 --- a/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj +++ b/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs b/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs index dc5fb23..6d77e97 100644 --- a/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs +++ b/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs @@ -1,16 +1,82 @@ +using Spectre.Console; +using Spectre.Console.Cli; +using Spectre.Console.Testing; + namespace AltGen.Console.Tests.Unit; public class AddConfigCommandTests { - [Fact] - public Task ExecuteAsync_WhenSettingsDoNotExist_ItShouldCreateSettings() + readonly Mock _fileSystem = new(); + readonly IAnsiConsole _testConsole = new TestConsole(); + readonly AddConfigCommand _sut; + + public AddConfigCommandTests() { - throw new NotImplementedException(); + _sut = new AddConfigCommand(_testConsole, _fileSystem.Object); + } + + + [Fact] + public async Task ExecuteAsync_WhenSettingsDoNotExist_ItShouldCreateSettings() + { + var testSettingsPath = "appsettings.json"; + + _fileSystem + .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) + .Returns(testSettingsPath); + + _fileSystem + .Setup(static x => x.File.Exists(It.IsAny())) + .Returns(false); + + var commandSettings = new AddConfigCommand.Settings(_fileSystem.Object) + { + Provider = "provider", + Key = "key", + }; + + var result = await _sut.ExecuteAsync(null!, commandSettings); + + result.Should().Be(0); + + _fileSystem + .Verify( + x => x.File.WriteAllTextAsync(testSettingsPath, It.IsAny(), default), + Times.Once + ); } [Fact] - public Task ExecuteAsync_WhenSettingsExist_ItShouldUpdateSettings() + public async Task ExecuteAsync_WhenSettingsExist_ItShouldUpdateSettings() { - throw new NotImplementedException(); + var testSettingsPath = "appsettings.json"; + + _fileSystem + .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) + .Returns(testSettingsPath); + + _fileSystem + .Setup(static x => x.File.Exists(It.IsAny())) + .Returns(true); + + _fileSystem + .Setup(static x => x.File.ReadAllTextAsync(It.IsAny(), default)) + .ReturnsAsync("{}"); + + var commandSettings = new AddConfigCommand.Settings(_fileSystem.Object) + { + Provider = "provider", + Key = "key", + }; + + var result = await _sut.ExecuteAsync(null!, commandSettings); + + result.Should().Be(0); + + _fileSystem + .Verify( + x => x.File.WriteAllTextAsync(testSettingsPath, It.IsAny(), default), + Times.Once + ); } } \ No newline at end of file diff --git a/src/AltGen.Console/Config/AppSettings.cs b/src/AltGen.Console/Config/AppSettings.cs index 9967e99..26bc33b 100644 --- a/src/AltGen.Console/Config/AppSettings.cs +++ b/src/AltGen.Console/Config/AppSettings.cs @@ -1,7 +1,14 @@ namespace AltGen.Console.Config; -record AppSettings(ProviderSettings[] Providers) +record AppSettings { + public ProviderSettings[] Providers { get; init; } = []; + + public AppSettings(ProviderSettings[] providers) + { + Providers = providers; + } + public AppSettings AddOrUpdateProvider(AddConfigCommand.Settings settings) { var updatedProviders = Providers.Select(p => From e9b9f7fc13cef7b284359aa040b4dd667bf389fa Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 10 Mar 2025 21:45:46 -0500 Subject: [PATCH 11/23] feat: add remove, list, and add config commands --- .../Unit/AddConfigCommandTests.cs | 74 +++++++--- .../Unit/GenerateCommandTests.cs | 6 + .../Unit/ListConfigCommandTests.cs | 126 ++++++++++++++++++ .../Unit/RemoveConfigCommandTests.cs | 111 +++++++++++++-- src/AltGen.Console.Tests/Usings.cs | 5 + .../Common/HostBuilderExtensions.cs | 3 +- src/AltGen.Console/Config/AddConfigCommand.cs | 2 +- src/AltGen.Console/Config/AppSettings.cs | 2 + .../Config/ListConfigCommand.cs | 34 +++++ .../Config/RemoveConfigCommand.cs | 5 +- 10 files changed, 340 insertions(+), 28 deletions(-) create mode 100644 src/AltGen.Console.Tests/Unit/GenerateCommandTests.cs create mode 100644 src/AltGen.Console.Tests/Unit/ListConfigCommandTests.cs create mode 100644 src/AltGen.Console/Config/ListConfigCommand.cs diff --git a/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs b/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs index 6d77e97..15e3344 100644 --- a/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs +++ b/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs @@ -1,13 +1,9 @@ -using Spectre.Console; -using Spectre.Console.Cli; -using Spectre.Console.Testing; - namespace AltGen.Console.Tests.Unit; -public class AddConfigCommandTests +public class AddConfigCommandTests : IDisposable { readonly Mock _fileSystem = new(); - readonly IAnsiConsole _testConsole = new TestConsole(); + readonly TestConsole _testConsole = new(); readonly AddConfigCommand _sut; public AddConfigCommandTests() @@ -20,6 +16,11 @@ public class AddConfigCommandTests public async Task ExecuteAsync_WhenSettingsDoNotExist_ItShouldCreateSettings() { var testSettingsPath = "appsettings.json"; + var expectedAppSettings = new AppSettings([ + new("provider", "key", false) + ]); + var expectedJson = JsonSerializer.Serialize(expectedAppSettings, JsonOptions.Default); + _fileSystem .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) @@ -41,13 +42,56 @@ public class AddConfigCommandTests _fileSystem .Verify( - x => x.File.WriteAllTextAsync(testSettingsPath, It.IsAny(), default), + x => x.File.WriteAllTextAsync(testSettingsPath, expectedJson, default), Times.Once ); } [Fact] public async Task ExecuteAsync_WhenSettingsExist_ItShouldUpdateSettings() + { + var testSettingsPath = "appsettings.json"; + var existingAppSettings = new AppSettings([ + new("existing", "existing", false) + ]); + var existingJson = JsonSerializer.Serialize(existingAppSettings, JsonOptions.Default); + var expectedAppSettings = new AppSettings([ + new("existing", "key", true) + ]); + var expectedJson = JsonSerializer.Serialize(expectedAppSettings, JsonOptions.Default); + + _fileSystem + .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) + .Returns(testSettingsPath); + + _fileSystem + .Setup(static x => x.File.Exists(It.IsAny())) + .Returns(true); + + _fileSystem + .Setup(static x => x.File.ReadAllTextAsync(It.IsAny(), default)) + .ReturnsAsync(existingJson); + + var commandSettings = new AddConfigCommand.Settings(_fileSystem.Object) + { + Provider = "existing", + Key = "key", + Default = true, + }; + + var result = await _sut.ExecuteAsync(null!, commandSettings); + + result.Should().Be(0); + + _fileSystem + .Verify( + x => x.File.WriteAllTextAsync(testSettingsPath, expectedJson, default), + Times.Once + ); + } + + [Fact] + public async Task ExecuteAsync_WhenSettingsExistButCanNotBeDeserialized_ItShouldThrow() { var testSettingsPath = "appsettings.json"; @@ -61,7 +105,7 @@ public class AddConfigCommandTests _fileSystem .Setup(static x => x.File.ReadAllTextAsync(It.IsAny(), default)) - .ReturnsAsync("{}"); + .ReturnsAsync("null"); var commandSettings = new AddConfigCommand.Settings(_fileSystem.Object) { @@ -69,14 +113,14 @@ public class AddConfigCommandTests Key = "key", }; - var result = await _sut.ExecuteAsync(null!, commandSettings); + var act = async () => await _sut.ExecuteAsync(null!, commandSettings); - result.Should().Be(0); + await act.Should().ThrowAsync(); + } - _fileSystem - .Verify( - x => x.File.WriteAllTextAsync(testSettingsPath, It.IsAny(), default), - Times.Once - ); + public void Dispose() + { + _testConsole.Dispose(); + GC.SuppressFinalize(this); } } \ No newline at end of file diff --git a/src/AltGen.Console.Tests/Unit/GenerateCommandTests.cs b/src/AltGen.Console.Tests/Unit/GenerateCommandTests.cs new file mode 100644 index 0000000..61905b8 --- /dev/null +++ b/src/AltGen.Console.Tests/Unit/GenerateCommandTests.cs @@ -0,0 +1,6 @@ +namespace AltGen.Console.Tests.Unit; + +public class GenerateCommandTests +{ + +} \ No newline at end of file diff --git a/src/AltGen.Console.Tests/Unit/ListConfigCommandTests.cs b/src/AltGen.Console.Tests/Unit/ListConfigCommandTests.cs new file mode 100644 index 0000000..d17cf11 --- /dev/null +++ b/src/AltGen.Console.Tests/Unit/ListConfigCommandTests.cs @@ -0,0 +1,126 @@ +namespace AltGen.Console.Tests.Unit; + +public class ListConfigCommandTests : IDisposable +{ + readonly Mock _fileSystem = new(); + readonly TestConsole _testConsole = new(); + readonly ListConfigCommand _sut; + + public ListConfigCommandTests() + { + _sut = new ListConfigCommand(_testConsole, _fileSystem.Object); + } + + [Fact] + public async Task ExecuteAsync_WhenSettingsDoNotExist_ItShouldOutputNoSettings() + { + var testSettingsPath = "appsettings.json"; + + _fileSystem + .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) + .Returns(testSettingsPath); + + _fileSystem + .Setup(static x => x.File.Exists(It.IsAny())) + .Returns(false); + + var result = await _sut.ExecuteAsync(null!); + + result.Should().Be(0); + + _testConsole + .Output + .Should() + .Contain("No settings found."); + } + + [Fact] + public async Task ExecuteAsync_WhenSettingsExist_ItShouldOutputSettings() + { + var testSettingsPath = "appsettings.json"; + var testSettings = new AppSettings([ + new("provider", "key", false) + ]); + var testSettingsJson = JsonSerializer.Serialize(testSettings, JsonOptions.Default); + + _fileSystem + .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) + .Returns(testSettingsPath); + + _fileSystem + .Setup(static x => x.File.Exists(It.IsAny())) + .Returns(true); + + _fileSystem + .Setup(static x => x.File.ReadAllTextAsync(It.IsAny(), default)) + .ReturnsAsync(testSettingsJson); + + var result = await _sut.ExecuteAsync(null!); + + result.Should().Be(0); + + _testConsole + .Output + .Should() + .Contain("provider key"); + } + + [Fact] + public async Task ExecuteAsync_WhenSettingsExistAndDefault_ItShouldOutputSettings() + { + var testSettingsPath = "appsettings.json"; + var testSettings = new AppSettings([ + new("provider", "key", true) + ]); + var testSettingsJson = JsonSerializer.Serialize(testSettings, JsonOptions.Default); + + _fileSystem + .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) + .Returns(testSettingsPath); + + _fileSystem + .Setup(static x => x.File.Exists(It.IsAny())) + .Returns(true); + + _fileSystem + .Setup(static x => x.File.ReadAllTextAsync(It.IsAny(), default)) + .ReturnsAsync(testSettingsJson); + + var result = await _sut.ExecuteAsync(null!); + + result.Should().Be(0); + + _testConsole + .Output + .Should() + .Contain("provider key (default)"); + } + + [Fact] + public async Task ExecuteAsync_WhenSettingsCanNotBeDeserialized_ItShouldThrowConfigException() + { + var testSettingsPath = "appsettings.json"; + + _fileSystem + .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) + .Returns(testSettingsPath); + + _fileSystem + .Setup(static x => x.File.Exists(It.IsAny())) + .Returns(true); + + _fileSystem + .Setup(static x => x.File.ReadAllTextAsync(It.IsAny(), default)) + .ReturnsAsync("null"); + + var action = async () => await _sut.ExecuteAsync(null!); + + await action.Should().ThrowAsync(); + } + + public void Dispose() + { + _testConsole.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/src/AltGen.Console.Tests/Unit/RemoveConfigCommandTests.cs b/src/AltGen.Console.Tests/Unit/RemoveConfigCommandTests.cs index 50dea56..3b8fed3 100644 --- a/src/AltGen.Console.Tests/Unit/RemoveConfigCommandTests.cs +++ b/src/AltGen.Console.Tests/Unit/RemoveConfigCommandTests.cs @@ -1,23 +1,118 @@ namespace AltGen.Console.Tests.Unit; -public class RemoveConfigCommandTests +public class RemoveConfigCommandTests : IDisposable { - [Fact] - public Task ExecuteAsync_WhenSettingsDoNotExist_ItShouldThrow() + readonly Mock _fileSystem = new(); + readonly TestConsole _testConsole = new(); + readonly RemoveConfigCommand _sut; + + public RemoveConfigCommandTests() { - throw new NotImplementedException(); + _sut = new RemoveConfigCommand(_fileSystem.Object, _testConsole); + } + + [Fact] + public async Task ExecuteAsync_WhenSettingsDoNotExist_ItShouldThrow() + { + var testSettingsPath = "appsettings.json"; + + _fileSystem + .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) + .Returns(testSettingsPath); + + _fileSystem + .Setup(static x => x.Path.Exists(It.IsAny())) + .Returns(false); + + var commandSettings = new RemoveConfigCommand.Settings(_fileSystem.Object) + { + Provider = "provider", + }; + + var action = async () => await _sut.ExecuteAsync(null!, commandSettings); + + await action.Should().ThrowAsync(); } [Fact] - public Task ExecuteAsync_WhenProviderDoesNotExist_ItShouldDoNothing() + public async Task ExecuteAsync_WhenProviderDoesNotExist_ItShouldDoNothing() { - throw new NotImplementedException(); + var testSettingsPath = "appsettings.json"; + var testSettings = new AppSettings([]); + var testSettingsJson = JsonSerializer.Serialize(testSettings, JsonOptions.Default); + + _fileSystem + .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) + .Returns(testSettingsPath); + + _fileSystem + .Setup(static x => x.Path.Exists(It.IsAny())) + .Returns(true); + + _fileSystem + .Setup(static x => x.File.ReadAllTextAsync(It.IsAny(), default)) + .ReturnsAsync(testSettingsJson); + + var commandSettings = new RemoveConfigCommand.Settings(_fileSystem.Object) + { + Provider = "provider", + }; + + var result = await _sut.ExecuteAsync(null!, commandSettings); + + result.Should().Be(0); + + _fileSystem + .Verify( + x => x.File.WriteAllTextAsync(testSettingsPath, testSettingsJson, default), + Times.Once + ); } [Fact] - public Task ExecuteAsync_WhenProviderExists_ItShouldRemoveProviderFromSettings() + public async Task ExecuteAsync_WhenProviderExists_ItShouldRemoveProviderFromSettings() { - throw new NotImplementedException(); + var testSettingsPath = "appsettings.json"; + var testSettings = new AppSettings([ + new("provider", "key", false) + ]); + var testSettingsJson = JsonSerializer.Serialize(testSettings, JsonOptions.Default); + + _fileSystem + .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) + .Returns(testSettingsPath); + + _fileSystem + .Setup(static x => x.Path.Exists(It.IsAny())) + .Returns(true); + + _fileSystem + .Setup(static x => x.File.ReadAllTextAsync(It.IsAny(), default)) + .ReturnsAsync(testSettingsJson); + + var commandSettings = new RemoveConfigCommand.Settings(_fileSystem.Object) + { + Provider = "provider", + }; + + var result = await _sut.ExecuteAsync(null!, commandSettings); + + result.Should().Be(0); + + var expectedSettings = new AppSettings([]); + var expectedSettingsJson = JsonSerializer.Serialize(expectedSettings, JsonOptions.Default); + + _fileSystem + .Verify( + x => x.File.WriteAllTextAsync(testSettingsPath, expectedSettingsJson, default), + Times.Once + ); + } + + public void Dispose() + { + _testConsole.Dispose(); + GC.SuppressFinalize(this); } } \ No newline at end of file diff --git a/src/AltGen.Console.Tests/Usings.cs b/src/AltGen.Console.Tests/Usings.cs index cf6c14c..83582f6 100644 --- a/src/AltGen.Console.Tests/Usings.cs +++ b/src/AltGen.Console.Tests/Usings.cs @@ -1,5 +1,7 @@ global using System.IO.Abstractions; +global using System.Text.Json; +global using AltGen.Console.Common; global using AltGen.Console.Config; global using AltGen.Console.Generate; @@ -8,3 +10,6 @@ global using FluentAssertions; global using Moq; global using RichardSzalay.MockHttp; + +global using Spectre.Console; +global using Spectre.Console.Testing; diff --git a/src/AltGen.Console/Common/HostBuilderExtensions.cs b/src/AltGen.Console/Common/HostBuilderExtensions.cs index 97c686d..8a08ab1 100644 --- a/src/AltGen.Console/Common/HostBuilderExtensions.cs +++ b/src/AltGen.Console/Common/HostBuilderExtensions.cs @@ -9,12 +9,11 @@ static class HostBuilderExtensions app.Configure(static c => { - c.PropagateExceptions(); - c.AddBranch("config", static c => { c.AddCommand("add"); c.AddCommand("remove"); + c.AddCommand("list"); }); }); diff --git a/src/AltGen.Console/Config/AddConfigCommand.cs b/src/AltGen.Console/Config/AddConfigCommand.cs index 597bd39..ca51f94 100644 --- a/src/AltGen.Console/Config/AddConfigCommand.cs +++ b/src/AltGen.Console/Config/AddConfigCommand.cs @@ -24,7 +24,7 @@ sealed class AddConfigCommand( [Description("Set the provider as the default.")] public bool Default { get; init; } - public string SettingsPath => _fileSystem.Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + public string SettingsPath => _fileSystem.Path.Combine(AppContext.BaseDirectory, AppSettings.SettingsFileName); } public override async Task ExecuteAsync(CommandContext context, Settings settings) diff --git a/src/AltGen.Console/Config/AppSettings.cs b/src/AltGen.Console/Config/AppSettings.cs index 26bc33b..a808f97 100644 --- a/src/AltGen.Console/Config/AppSettings.cs +++ b/src/AltGen.Console/Config/AppSettings.cs @@ -2,6 +2,8 @@ namespace AltGen.Console.Config; record AppSettings { + public const string SettingsFileName = "appsettings.json"; + public ProviderSettings[] Providers { get; init; } = []; public AppSettings(ProviderSettings[] providers) diff --git a/src/AltGen.Console/Config/ListConfigCommand.cs b/src/AltGen.Console/Config/ListConfigCommand.cs new file mode 100644 index 0000000..31d1918 --- /dev/null +++ b/src/AltGen.Console/Config/ListConfigCommand.cs @@ -0,0 +1,34 @@ + +namespace AltGen.Console.Config; + +sealed class ListConfigCommand(IAnsiConsole console, IFileSystem fileSystem) : AsyncCommand +{ + readonly IAnsiConsole _console = console; + readonly IFileSystem _fileSystem = fileSystem; + + public override async Task ExecuteAsync(CommandContext context) + { + var settingsPath = _fileSystem.Path.Combine(AppContext.BaseDirectory, AppSettings.SettingsFileName); + var settingsExist = _fileSystem.File.Exists(settingsPath); + + if (settingsExist is false) + { + _console.MarkupLine("No settings found."); + return 0; + } + + var settingsJson = await _fileSystem.File.ReadAllTextAsync(settingsPath); + var appSettings = JsonSerializer.Deserialize(settingsJson, JsonOptions.Default) + ?? throw new ConfigException("Failed to deserialize settings."); + + foreach (var provider in appSettings.Providers) + { + var providerName = provider.Provider; + var providerKey = provider.Key; + var isDefault = provider.Default ? " (default)" : string.Empty; + _console.MarkupLine($"[bold]{providerName}[/] [dim]{providerKey}[/]{isDefault}"); + } + + return 0; + } +} \ No newline at end of file diff --git a/src/AltGen.Console/Config/RemoveConfigCommand.cs b/src/AltGen.Console/Config/RemoveConfigCommand.cs index 3af4423..7bc0540 100644 --- a/src/AltGen.Console/Config/RemoveConfigCommand.cs +++ b/src/AltGen.Console/Config/RemoveConfigCommand.cs @@ -16,7 +16,7 @@ sealed class RemoveConfigCommand( [Description("The provider to remove.")] public string Provider { get; init; } = string.Empty; - public string SettingsPath => _fileSystem.Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + public string SettingsPath => _fileSystem.Path.Combine(AppContext.BaseDirectory, AppSettings.SettingsFileName); } public override async Task ExecuteAsync(CommandContext context, Settings settings) @@ -29,7 +29,8 @@ sealed class RemoveConfigCommand( } var settingsJson = await _fileSystem.File.ReadAllTextAsync(settings.SettingsPath); - var appSettings = JsonSerializer.Deserialize(settingsJson, JsonOptions.Default) ?? throw new ConfigException("Failed to deserialize app settings."); + var appSettings = JsonSerializer.Deserialize(settingsJson, JsonOptions.Default) + ?? throw new ConfigException("Failed to deserialize app settings."); var updatedAppSettings = appSettings.RemoveProvider(settings); var updatedJson = JsonSerializer.Serialize(updatedAppSettings, JsonOptions.Default); await _fileSystem.File.WriteAllTextAsync(settings.SettingsPath, updatedJson); From 414e712f515f5b18c8d118921efeb52f52f3d280 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Fri, 14 Mar 2025 22:02:22 -0500 Subject: [PATCH 12/23] feat: add app settings manager to do the reading and writing --- src/AltGen.API/AltGen.API.csproj | 1 + .../Unit/AddConfigCommandTests.cs | 91 +++++----------- .../Unit/AppSettingsManagerTests.cs | 100 ++++++++++++++++++ .../Unit/AppSettingsTests.cs | 10 +- .../Unit/ListConfigCommandTests.cs | 68 +++--------- .../Unit/RemoveConfigCommandTests.cs | 67 ++++-------- src/AltGen.Console/AltGen.Console.csproj | 1 + src/AltGen.Console/Config/AddConfigCommand.cs | 25 ++--- .../Config/AppSettingsManager.cs | 30 ++++++ .../Config/IAppSettingsManager.cs | 8 ++ .../Config/ListConfigCommand.cs | 16 ++- .../Config/RemoveConfigCommand.cs | 23 ++-- src/AltGen.Console/Generate/IAltGenService.cs | 2 +- src/AltGen.Console/Program.cs | 1 + 14 files changed, 229 insertions(+), 214 deletions(-) create mode 100644 src/AltGen.Console.Tests/Unit/AppSettingsManagerTests.cs create mode 100644 src/AltGen.Console/Config/AppSettingsManager.cs create mode 100644 src/AltGen.Console/Config/IAppSettingsManager.cs diff --git a/src/AltGen.API/AltGen.API.csproj b/src/AltGen.API/AltGen.API.csproj index d73d24c..3aabe5f 100644 --- a/src/AltGen.API/AltGen.API.csproj +++ b/src/AltGen.API/AltGen.API.csproj @@ -11,6 +11,7 @@ + diff --git a/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs b/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs index 15e3344..a7af256 100644 --- a/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs +++ b/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs @@ -2,35 +2,28 @@ namespace AltGen.Console.Tests.Unit; public class AddConfigCommandTests : IDisposable { - readonly Mock _fileSystem = new(); + readonly Mock _mockSettingsManager = new(); readonly TestConsole _testConsole = new(); readonly AddConfigCommand _sut; public AddConfigCommandTests() { - _sut = new AddConfigCommand(_testConsole, _fileSystem.Object); + _sut = new AddConfigCommand(_testConsole, _mockSettingsManager.Object); } [Fact] public async Task ExecuteAsync_WhenSettingsDoNotExist_ItShouldCreateSettings() { - var testSettingsPath = "appsettings.json"; var expectedAppSettings = new AppSettings([ new("provider", "key", false) ]); - var expectedJson = JsonSerializer.Serialize(expectedAppSettings, JsonOptions.Default); - - _fileSystem - .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) - .Returns(testSettingsPath); - - _fileSystem - .Setup(static x => x.File.Exists(It.IsAny())) + _mockSettingsManager + .Setup(static x => x.AppSettingsExist()) .Returns(false); - var commandSettings = new AddConfigCommand.Settings(_fileSystem.Object) + var commandSettings = new AddConfigCommand.Settings() { Provider = "provider", Key = "key", @@ -40,39 +33,36 @@ public class AddConfigCommandTests : IDisposable result.Should().Be(0); - _fileSystem - .Verify( - x => x.File.WriteAllTextAsync(testSettingsPath, expectedJson, default), - Times.Once - ); + _mockSettingsManager.Verify( + static x => x.SaveAppSettingsAsync(It.Is( + static x => x.Providers[0].Provider == "provider" && + x.Providers[0].Key == "key" && + x.Providers[0].Default == false + )), + Times.Once + ); } [Fact] public async Task ExecuteAsync_WhenSettingsExist_ItShouldUpdateSettings() { - var testSettingsPath = "appsettings.json"; var existingAppSettings = new AppSettings([ new("existing", "existing", false) ]); - var existingJson = JsonSerializer.Serialize(existingAppSettings, JsonOptions.Default); + var expectedAppSettings = new AppSettings([ new("existing", "key", true) ]); - var expectedJson = JsonSerializer.Serialize(expectedAppSettings, JsonOptions.Default); - _fileSystem - .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) - .Returns(testSettingsPath); - - _fileSystem - .Setup(static x => x.File.Exists(It.IsAny())) + _mockSettingsManager + .Setup(static x => x.AppSettingsExist()) .Returns(true); - _fileSystem - .Setup(static x => x.File.ReadAllTextAsync(It.IsAny(), default)) - .ReturnsAsync(existingJson); + _mockSettingsManager + .Setup(static x => x.GetAppSettingsAsync()) + .ReturnsAsync(existingAppSettings); - var commandSettings = new AddConfigCommand.Settings(_fileSystem.Object) + var commandSettings = new AddConfigCommand.Settings() { Provider = "existing", Key = "key", @@ -83,39 +73,14 @@ public class AddConfigCommandTests : IDisposable result.Should().Be(0); - _fileSystem - .Verify( - x => x.File.WriteAllTextAsync(testSettingsPath, expectedJson, default), - Times.Once - ); - } - - [Fact] - public async Task ExecuteAsync_WhenSettingsExistButCanNotBeDeserialized_ItShouldThrow() - { - var testSettingsPath = "appsettings.json"; - - _fileSystem - .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) - .Returns(testSettingsPath); - - _fileSystem - .Setup(static x => x.File.Exists(It.IsAny())) - .Returns(true); - - _fileSystem - .Setup(static x => x.File.ReadAllTextAsync(It.IsAny(), default)) - .ReturnsAsync("null"); - - var commandSettings = new AddConfigCommand.Settings(_fileSystem.Object) - { - Provider = "provider", - Key = "key", - }; - - var act = async () => await _sut.ExecuteAsync(null!, commandSettings); - - await act.Should().ThrowAsync(); + _mockSettingsManager.Verify( + static x => x.SaveAppSettingsAsync(It.Is( + static x => x.Providers[0].Provider == "existing" && + x.Providers[0].Key == "key" && + x.Providers[0].Default == true + )), + Times.Once + ); } public void Dispose() diff --git a/src/AltGen.Console.Tests/Unit/AppSettingsManagerTests.cs b/src/AltGen.Console.Tests/Unit/AppSettingsManagerTests.cs new file mode 100644 index 0000000..164d6a9 --- /dev/null +++ b/src/AltGen.Console.Tests/Unit/AppSettingsManagerTests.cs @@ -0,0 +1,100 @@ +namespace AltGen.Console.Tests.Unit; + +public class AppSettingsManagerTests +{ + readonly Mock _mockFileSystem = new(); + readonly AppSettingsManager _sut; + + public AppSettingsManagerTests() + { + _mockFileSystem + .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) + .Returns("path"); + + _sut = new AppSettingsManager(_mockFileSystem.Object); + } + + [Fact] + public void AppSettingsExist_WhenSettingsDoNotExist_ItShouldReturnFalse() + { + _mockFileSystem + .Setup(static x => x.Path.Exists(It.IsAny())) + .Returns(false); + + var result = _sut.AppSettingsExist(); + + result.Should().BeFalse(); + } + + [Fact] + public void AppSettingsExist_WhenSettingsExist_ItShouldReturnTrue() + { + _mockFileSystem + .Setup(static x => x.Path.Exists(It.IsAny())) + .Returns(true); + + var result = _sut.AppSettingsExist(); + + result.Should().BeTrue(); + } + + [Fact] + public async Task GetAppSettingsAsync_WhenSettingsExist_ItShouldReturnSettings() + { + var testSettings = new AppSettings([ + new("provider", "key", false) + ]); + + var json = JsonSerializer.Serialize(testSettings, JsonOptions.Default); + + _mockFileSystem + .Setup(static x => x.File.ReadAllTextAsync(It.IsAny(), default)) + .ReturnsAsync(json); + + var result = await _sut.GetAppSettingsAsync(); + + result.Should().BeEquivalentTo(testSettings); + } + + [Fact] + public async Task GetAppSettingsAsync_WhenDeserializingSettingsIsNull_ItShouldReturnEmptySettings() + { + _mockFileSystem + .Setup(static x => x.File.ReadAllTextAsync(It.IsAny(), default)) + .ReturnsAsync("null"); + + var result = await _sut.GetAppSettingsAsync(); + + result.Should().BeEquivalentTo(new AppSettings([])); + } + + [Fact] + public async Task GetAppSettingsAsync_WhenSettingsDoNotExist_ItShouldThrow() + { + _mockFileSystem + .Setup(static x => x.File.ReadAllTextAsync(It.IsAny(), default)) + .ThrowsAsync(new FileNotFoundException()); + + var action = _sut.GetAppSettingsAsync; + + await action.Should().ThrowAsync(); + } + + [Fact] + public async Task SaveAppSettingsAsync_WhenCalled_ItShouldSaveSettings() + { + _mockFileSystem + .Setup(static x => x.File.WriteAllTextAsync(It.IsAny(), It.IsAny(), default)) + .Returns(Task.CompletedTask); + + var testSettings = new AppSettings([ + new("provider", "key", false) + ]); + + var json = JsonSerializer.Serialize(testSettings, JsonOptions.Default); + + await _sut.SaveAppSettingsAsync(testSettings); + + _mockFileSystem.Verify(x => x.File.WriteAllTextAsync(It.IsAny(), json, default), Times.Once); + } +} \ No newline at end of file diff --git a/src/AltGen.Console.Tests/Unit/AppSettingsTests.cs b/src/AltGen.Console.Tests/Unit/AppSettingsTests.cs index bb5b868..e50e9ed 100644 --- a/src/AltGen.Console.Tests/Unit/AppSettingsTests.cs +++ b/src/AltGen.Console.Tests/Unit/AppSettingsTests.cs @@ -2,14 +2,12 @@ namespace AltGen.Console.Tests.Unit; public class AppSettingsTests { - readonly Mock _fileSystemMock = new(); - [Fact] public void AddOrUpdateProvider_WhenProviderDoesNotExist_ItShouldAddProvider() { var providerSettings = new ProviderSettings("provider", "key", true); - var commandSettings = new AddConfigCommand.Settings(_fileSystemMock.Object) + var commandSettings = new AddConfigCommand.Settings() { Provider = providerSettings.Provider, Key = providerSettings.Key, @@ -29,7 +27,7 @@ public class AppSettingsTests var providerSettings = new ProviderSettings("provider", "key", true); var existingProviderSettings = new ProviderSettings("provider", "key", false); - var commandSettings = new AddConfigCommand.Settings(_fileSystemMock.Object) + var commandSettings = new AddConfigCommand.Settings() { Provider = providerSettings.Provider, Key = providerSettings.Key, @@ -48,7 +46,7 @@ public class AppSettingsTests var providerSettings = new ProviderSettings("claude", "key", true); var existingProviderSettings = new ProviderSettings("gemini", "key", true); - var commandSettings = new AddConfigCommand.Settings(_fileSystemMock.Object) + var commandSettings = new AddConfigCommand.Settings() { Provider = providerSettings.Provider, Key = providerSettings.Key, @@ -69,7 +67,7 @@ public class AppSettingsTests { var existingProviderSettings = new ProviderSettings("provider", "key", true); - var commandSettings = new RemoveConfigCommand.Settings(_fileSystemMock.Object) + var commandSettings = new RemoveConfigCommand.Settings() { Provider = existingProviderSettings.Provider, }; diff --git a/src/AltGen.Console.Tests/Unit/ListConfigCommandTests.cs b/src/AltGen.Console.Tests/Unit/ListConfigCommandTests.cs index d17cf11..d3120fd 100644 --- a/src/AltGen.Console.Tests/Unit/ListConfigCommandTests.cs +++ b/src/AltGen.Console.Tests/Unit/ListConfigCommandTests.cs @@ -2,26 +2,20 @@ namespace AltGen.Console.Tests.Unit; public class ListConfigCommandTests : IDisposable { - readonly Mock _fileSystem = new(); + readonly Mock _mockSettingsManager = new(); readonly TestConsole _testConsole = new(); readonly ListConfigCommand _sut; public ListConfigCommandTests() { - _sut = new ListConfigCommand(_testConsole, _fileSystem.Object); + _sut = new ListConfigCommand(_testConsole, _mockSettingsManager.Object); } [Fact] public async Task ExecuteAsync_WhenSettingsDoNotExist_ItShouldOutputNoSettings() { - var testSettingsPath = "appsettings.json"; - - _fileSystem - .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) - .Returns(testSettingsPath); - - _fileSystem - .Setup(static x => x.File.Exists(It.IsAny())) + _mockSettingsManager + .Setup(static x => x.AppSettingsExist()) .Returns(false); var result = await _sut.ExecuteAsync(null!); @@ -37,23 +31,17 @@ public class ListConfigCommandTests : IDisposable [Fact] public async Task ExecuteAsync_WhenSettingsExist_ItShouldOutputSettings() { - var testSettingsPath = "appsettings.json"; var testSettings = new AppSettings([ new("provider", "key", false) ]); - var testSettingsJson = JsonSerializer.Serialize(testSettings, JsonOptions.Default); - _fileSystem - .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) - .Returns(testSettingsPath); - - _fileSystem - .Setup(static x => x.File.Exists(It.IsAny())) + _mockSettingsManager + .Setup(static x => x.AppSettingsExist()) .Returns(true); - _fileSystem - .Setup(static x => x.File.ReadAllTextAsync(It.IsAny(), default)) - .ReturnsAsync(testSettingsJson); + _mockSettingsManager + .Setup(static x => x.GetAppSettingsAsync()) + .ReturnsAsync(testSettings); var result = await _sut.ExecuteAsync(null!); @@ -68,23 +56,17 @@ public class ListConfigCommandTests : IDisposable [Fact] public async Task ExecuteAsync_WhenSettingsExistAndDefault_ItShouldOutputSettings() { - var testSettingsPath = "appsettings.json"; var testSettings = new AppSettings([ new("provider", "key", true) ]); - var testSettingsJson = JsonSerializer.Serialize(testSettings, JsonOptions.Default); - _fileSystem - .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) - .Returns(testSettingsPath); - - _fileSystem - .Setup(static x => x.File.Exists(It.IsAny())) + _mockSettingsManager + .Setup(static x => x.AppSettingsExist()) .Returns(true); - _fileSystem - .Setup(static x => x.File.ReadAllTextAsync(It.IsAny(), default)) - .ReturnsAsync(testSettingsJson); + _mockSettingsManager + .Setup(static x => x.GetAppSettingsAsync()) + .ReturnsAsync(testSettings); var result = await _sut.ExecuteAsync(null!); @@ -96,28 +78,6 @@ public class ListConfigCommandTests : IDisposable .Contain("provider key (default)"); } - [Fact] - public async Task ExecuteAsync_WhenSettingsCanNotBeDeserialized_ItShouldThrowConfigException() - { - var testSettingsPath = "appsettings.json"; - - _fileSystem - .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) - .Returns(testSettingsPath); - - _fileSystem - .Setup(static x => x.File.Exists(It.IsAny())) - .Returns(true); - - _fileSystem - .Setup(static x => x.File.ReadAllTextAsync(It.IsAny(), default)) - .ReturnsAsync("null"); - - var action = async () => await _sut.ExecuteAsync(null!); - - await action.Should().ThrowAsync(); - } - public void Dispose() { _testConsole.Dispose(); diff --git a/src/AltGen.Console.Tests/Unit/RemoveConfigCommandTests.cs b/src/AltGen.Console.Tests/Unit/RemoveConfigCommandTests.cs index 3b8fed3..e49c1ee 100644 --- a/src/AltGen.Console.Tests/Unit/RemoveConfigCommandTests.cs +++ b/src/AltGen.Console.Tests/Unit/RemoveConfigCommandTests.cs @@ -2,29 +2,23 @@ namespace AltGen.Console.Tests.Unit; public class RemoveConfigCommandTests : IDisposable { - readonly Mock _fileSystem = new(); + readonly Mock _mockSettingsManager = new(); readonly TestConsole _testConsole = new(); readonly RemoveConfigCommand _sut; public RemoveConfigCommandTests() { - _sut = new RemoveConfigCommand(_fileSystem.Object, _testConsole); + _sut = new RemoveConfigCommand(_testConsole, _mockSettingsManager.Object); } [Fact] public async Task ExecuteAsync_WhenSettingsDoNotExist_ItShouldThrow() { - var testSettingsPath = "appsettings.json"; - - _fileSystem - .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) - .Returns(testSettingsPath); - - _fileSystem - .Setup(static x => x.Path.Exists(It.IsAny())) + _mockSettingsManager + .Setup(static x => x.AppSettingsExist()) .Returns(false); - var commandSettings = new RemoveConfigCommand.Settings(_fileSystem.Object) + var commandSettings = new RemoveConfigCommand.Settings() { Provider = "provider", }; @@ -38,23 +32,17 @@ public class RemoveConfigCommandTests : IDisposable [Fact] public async Task ExecuteAsync_WhenProviderDoesNotExist_ItShouldDoNothing() { - var testSettingsPath = "appsettings.json"; var testSettings = new AppSettings([]); - var testSettingsJson = JsonSerializer.Serialize(testSettings, JsonOptions.Default); - _fileSystem - .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) - .Returns(testSettingsPath); - - _fileSystem - .Setup(static x => x.Path.Exists(It.IsAny())) + _mockSettingsManager + .Setup(static x => x.AppSettingsExist()) .Returns(true); - _fileSystem - .Setup(static x => x.File.ReadAllTextAsync(It.IsAny(), default)) - .ReturnsAsync(testSettingsJson); + _mockSettingsManager + .Setup(static x => x.GetAppSettingsAsync()) + .ReturnsAsync(testSettings); - var commandSettings = new RemoveConfigCommand.Settings(_fileSystem.Object) + var commandSettings = new RemoveConfigCommand.Settings() { Provider = "provider", }; @@ -63,35 +51,27 @@ public class RemoveConfigCommandTests : IDisposable result.Should().Be(0); - _fileSystem - .Verify( - x => x.File.WriteAllTextAsync(testSettingsPath, testSettingsJson, default), - Times.Once - ); + _mockSettingsManager.Verify(x => x.SaveAppSettingsAsync(testSettings), Times.Once); } [Fact] public async Task ExecuteAsync_WhenProviderExists_ItShouldRemoveProviderFromSettings() { - var testSettingsPath = "appsettings.json"; var testSettings = new AppSettings([ new("provider", "key", false) ]); - var testSettingsJson = JsonSerializer.Serialize(testSettings, JsonOptions.Default); - _fileSystem - .Setup(static x => x.Path.Combine(It.IsAny(), It.IsAny())) - .Returns(testSettingsPath); + var expectedSettings = new AppSettings([]); - _fileSystem - .Setup(static x => x.Path.Exists(It.IsAny())) + _mockSettingsManager + .Setup(static x => x.AppSettingsExist()) .Returns(true); - _fileSystem - .Setup(static x => x.File.ReadAllTextAsync(It.IsAny(), default)) - .ReturnsAsync(testSettingsJson); + _mockSettingsManager + .Setup(static x => x.GetAppSettingsAsync()) + .ReturnsAsync(testSettings); - var commandSettings = new RemoveConfigCommand.Settings(_fileSystem.Object) + var commandSettings = new RemoveConfigCommand.Settings() { Provider = "provider", }; @@ -100,14 +80,7 @@ public class RemoveConfigCommandTests : IDisposable result.Should().Be(0); - var expectedSettings = new AppSettings([]); - var expectedSettingsJson = JsonSerializer.Serialize(expectedSettings, JsonOptions.Default); - - _fileSystem - .Verify( - x => x.File.WriteAllTextAsync(testSettingsPath, expectedSettingsJson, default), - Times.Once - ); + _mockSettingsManager.Verify(x => x.SaveAppSettingsAsync(expectedSettings), Times.Once); } public void Dispose() diff --git a/src/AltGen.Console/AltGen.Console.csproj b/src/AltGen.Console/AltGen.Console.csproj index f192040..089a558 100644 --- a/src/AltGen.Console/AltGen.Console.csproj +++ b/src/AltGen.Console/AltGen.Console.csproj @@ -17,6 +17,7 @@ + diff --git a/src/AltGen.Console/Config/AddConfigCommand.cs b/src/AltGen.Console/Config/AddConfigCommand.cs index ca51f94..5d69f08 100644 --- a/src/AltGen.Console/Config/AddConfigCommand.cs +++ b/src/AltGen.Console/Config/AddConfigCommand.cs @@ -2,16 +2,14 @@ namespace AltGen.Console.Config; sealed class AddConfigCommand( IAnsiConsole console, - IFileSystem fileSystem + IAppSettingsManager settingsManager ) : AsyncCommand { readonly IAnsiConsole _console = console; - readonly IFileSystem _fileSystem = fileSystem; + readonly IAppSettingsManager _settingsManager = settingsManager; - public class Settings(IFileSystem filesystem) : CommandSettings + public class Settings : CommandSettings { - readonly IFileSystem _fileSystem = filesystem; - [CommandArgument(1, "")] [Description("The provider to configure.")] public string Provider { get; init; } = string.Empty; @@ -23,32 +21,23 @@ sealed class AddConfigCommand( [CommandOption("-d|--default")] [Description("Set the provider as the default.")] public bool Default { get; init; } - - public string SettingsPath => _fileSystem.Path.Combine(AppContext.BaseDirectory, AppSettings.SettingsFileName); } public override async Task ExecuteAsync(CommandContext context, Settings settings) { - var settingsExist = _fileSystem.File.Exists(settings.SettingsPath); - - if (settingsExist is false) + if (_settingsManager.AppSettingsExist() is false) { var appSettings = new AppSettings([ new(settings.Provider, settings.Key, settings.Default) ]); - - var json = JsonSerializer.Serialize(appSettings, JsonOptions.Default); - await _fileSystem.File.WriteAllTextAsync(settings.SettingsPath, json); + await _settingsManager.SaveAppSettingsAsync(appSettings); _console.MarkupLine($"[bold]{settings.Provider}[/] has been configured."); return 0; } - var existingJson = await _fileSystem.File.ReadAllTextAsync(settings.SettingsPath); - var existingAppSettings = JsonSerializer.Deserialize(existingJson, JsonOptions.Default) - ?? throw new ConfigException("Failed to deserialize settings."); + var existingAppSettings = await _settingsManager.GetAppSettingsAsync(); var updatedAppSettings = existingAppSettings.AddOrUpdateProvider(settings); - var udpatedJson = JsonSerializer.Serialize(updatedAppSettings, JsonOptions.Default); - await _fileSystem.File.WriteAllTextAsync(settings.SettingsPath, udpatedJson); + await _settingsManager.SaveAppSettingsAsync(updatedAppSettings); _console.MarkupLine($"[bold]{settings.Provider}[/] has been configured."); return 0; } diff --git a/src/AltGen.Console/Config/AppSettingsManager.cs b/src/AltGen.Console/Config/AppSettingsManager.cs new file mode 100644 index 0000000..db8ae4e --- /dev/null +++ b/src/AltGen.Console/Config/AppSettingsManager.cs @@ -0,0 +1,30 @@ +namespace AltGen.Console.Config; + +class AppSettingsManager(IFileSystem fileSystem) : IAppSettingsManager +{ + const string SettingsFileName = "appsettings.json"; + readonly IFileSystem _fileSystem = fileSystem; + + public bool AppSettingsExist() + { + return _fileSystem.Path.Exists(SettingsFileName); + } + + public async Task GetAppSettingsAsync() + { + var existingJson = await _fileSystem.File.ReadAllTextAsync(GetSettingsPath()); + var existingAppSettings = JsonSerializer.Deserialize(existingJson, JsonOptions.Default) ?? new AppSettings([]); + return existingAppSettings; + } + + public async Task SaveAppSettingsAsync(AppSettings appSettings) + { + var json = JsonSerializer.Serialize(appSettings, JsonOptions.Default); + await _fileSystem.File.WriteAllTextAsync(GetSettingsPath(), json); + } + + string GetSettingsPath() + { + return _fileSystem.Path.Combine(AppContext.BaseDirectory, SettingsFileName); + } +} \ No newline at end of file diff --git a/src/AltGen.Console/Config/IAppSettingsManager.cs b/src/AltGen.Console/Config/IAppSettingsManager.cs new file mode 100644 index 0000000..67a5886 --- /dev/null +++ b/src/AltGen.Console/Config/IAppSettingsManager.cs @@ -0,0 +1,8 @@ +namespace AltGen.Console.Config; + +interface IAppSettingsManager +{ + bool AppSettingsExist(); + Task GetAppSettingsAsync(); + Task SaveAppSettingsAsync(AppSettings appSettings); +} \ No newline at end of file diff --git a/src/AltGen.Console/Config/ListConfigCommand.cs b/src/AltGen.Console/Config/ListConfigCommand.cs index 31d1918..8f42182 100644 --- a/src/AltGen.Console/Config/ListConfigCommand.cs +++ b/src/AltGen.Console/Config/ListConfigCommand.cs @@ -1,25 +1,23 @@ namespace AltGen.Console.Config; -sealed class ListConfigCommand(IAnsiConsole console, IFileSystem fileSystem) : AsyncCommand +sealed class ListConfigCommand( + IAnsiConsole console, + IAppSettingsManager settingsManager +) : AsyncCommand { readonly IAnsiConsole _console = console; - readonly IFileSystem _fileSystem = fileSystem; + readonly IAppSettingsManager _settingsManager = settingsManager; public override async Task ExecuteAsync(CommandContext context) { - var settingsPath = _fileSystem.Path.Combine(AppContext.BaseDirectory, AppSettings.SettingsFileName); - var settingsExist = _fileSystem.File.Exists(settingsPath); - - if (settingsExist is false) + if (_settingsManager.AppSettingsExist() is false) { _console.MarkupLine("No settings found."); return 0; } - var settingsJson = await _fileSystem.File.ReadAllTextAsync(settingsPath); - var appSettings = JsonSerializer.Deserialize(settingsJson, JsonOptions.Default) - ?? throw new ConfigException("Failed to deserialize settings."); + var appSettings = await _settingsManager.GetAppSettingsAsync(); foreach (var provider in appSettings.Providers) { diff --git a/src/AltGen.Console/Config/RemoveConfigCommand.cs b/src/AltGen.Console/Config/RemoveConfigCommand.cs index 7bc0540..925f408 100644 --- a/src/AltGen.Console/Config/RemoveConfigCommand.cs +++ b/src/AltGen.Console/Config/RemoveConfigCommand.cs @@ -1,39 +1,30 @@ namespace AltGen.Console.Config; sealed class RemoveConfigCommand( - IFileSystem fileSystem, - IAnsiConsole console + IAnsiConsole console, + IAppSettingsManager settingsManager ) : AsyncCommand { - readonly IFileSystem _fileSystem = fileSystem; readonly IAnsiConsole _console = console; + readonly IAppSettingsManager _settingsManager = settingsManager; - public class Settings(IFileSystem fileSystem) : CommandSettings + public class Settings : CommandSettings { - readonly IFileSystem _fileSystem = fileSystem; - [CommandArgument(1, "")] [Description("The provider to remove.")] public string Provider { get; init; } = string.Empty; - - public string SettingsPath => _fileSystem.Path.Combine(AppContext.BaseDirectory, AppSettings.SettingsFileName); } public override async Task ExecuteAsync(CommandContext context, Settings settings) { - var exists = _fileSystem.Path.Exists(settings.SettingsPath); - - if (exists is false) + if (_settingsManager.AppSettingsExist() is false) { throw new ConfigException("No existing settings found."); } - var settingsJson = await _fileSystem.File.ReadAllTextAsync(settings.SettingsPath); - var appSettings = JsonSerializer.Deserialize(settingsJson, JsonOptions.Default) - ?? throw new ConfigException("Failed to deserialize app settings."); + var appSettings = await _settingsManager.GetAppSettingsAsync(); var updatedAppSettings = appSettings.RemoveProvider(settings); - var updatedJson = JsonSerializer.Serialize(updatedAppSettings, JsonOptions.Default); - await _fileSystem.File.WriteAllTextAsync(settings.SettingsPath, updatedJson); + await _settingsManager.SaveAppSettingsAsync(updatedAppSettings); _console.MarkupLine($"[bold]{settings.Provider}[/] has been removed."); return 0; } diff --git a/src/AltGen.Console/Generate/IAltGenService.cs b/src/AltGen.Console/Generate/IAltGenService.cs index 1b2b14b..81101a0 100644 --- a/src/AltGen.Console/Generate/IAltGenService.cs +++ b/src/AltGen.Console/Generate/IAltGenService.cs @@ -1,4 +1,4 @@ interface IAltGenService { Task GenerateAltTextAsync(GenerateAltTextRequest req); -} +} \ No newline at end of file diff --git a/src/AltGen.Console/Program.cs b/src/AltGen.Console/Program.cs index 48aaed7..cc4380e 100644 --- a/src/AltGen.Console/Program.cs +++ b/src/AltGen.Console/Program.cs @@ -3,6 +3,7 @@ .ConfigureServices(static (_, services) => { services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(AnsiConsole.Console); services.AddHttpClient() .AddStandardResilienceHandler(); From 45a0ead7d89caefe4fefc12d7359f3a0f65e250a Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sat, 15 Mar 2025 18:44:10 -0500 Subject: [PATCH 13/23] feat: success console app => api => gemini --- src/AltGen.Console/AltGen.Console.csproj | 2 +- .../Common/HostBuilderExtensions.cs | 7 +++ src/AltGen.Console/Common/Providers.cs | 17 +++++++ src/AltGen.Console/Config/AddConfigCommand.cs | 10 ++++ src/AltGen.Console/Config/AppSettings.cs | 15 ++++++ .../Config/ListConfigCommand.cs | 2 +- src/AltGen.Console/Generate/AltGenService.cs | 4 +- .../Generate/GenerateCommand.cs | 49 ++++++++++++------- src/AltGen.Console/Generated/Constants.cs | 2 +- 9 files changed, 86 insertions(+), 22 deletions(-) create mode 100644 src/AltGen.Console/Common/Providers.cs diff --git a/src/AltGen.Console/AltGen.Console.csproj b/src/AltGen.Console/AltGen.Console.csproj index 089a558..f4841cf 100644 --- a/src/AltGen.Console/AltGen.Console.csproj +++ b/src/AltGen.Console/AltGen.Console.csproj @@ -24,7 +24,7 @@ Generated $(GeneratedCodeDirectory)\Constants.cs - http://localhost:7297 + https://localhost:7297 diff --git a/src/AltGen.Console/Common/HostBuilderExtensions.cs b/src/AltGen.Console/Common/HostBuilderExtensions.cs index 8a08ab1..b1077e2 100644 --- a/src/AltGen.Console/Common/HostBuilderExtensions.cs +++ b/src/AltGen.Console/Common/HostBuilderExtensions.cs @@ -9,6 +9,13 @@ static class HostBuilderExtensions app.Configure(static c => { + c.SetExceptionHandler(static (ex, resolver) => + { + var console = resolver?.Resolve(typeof(IAnsiConsole)) as IAnsiConsole; + console?.WriteException(ex, ExceptionFormats.ShortenEverything); + return -99; + }); + c.AddBranch("config", static c => { c.AddCommand("add"); diff --git a/src/AltGen.Console/Common/Providers.cs b/src/AltGen.Console/Common/Providers.cs new file mode 100644 index 0000000..a4801de --- /dev/null +++ b/src/AltGen.Console/Common/Providers.cs @@ -0,0 +1,17 @@ +using System.Globalization; + +namespace AltGen.Console.Common; + +static class Providers +{ + const string Gemini = "gemini"; + + public static bool IsSupported(string provider) + { + return provider.ToLower(CultureInfo.InvariantCulture) switch + { + Gemini => true, + _ => false + }; + } +} \ No newline at end of file diff --git a/src/AltGen.Console/Config/AddConfigCommand.cs b/src/AltGen.Console/Config/AddConfigCommand.cs index 5d69f08..277b947 100644 --- a/src/AltGen.Console/Config/AddConfigCommand.cs +++ b/src/AltGen.Console/Config/AddConfigCommand.cs @@ -21,6 +21,16 @@ sealed class AddConfigCommand( [CommandOption("-d|--default")] [Description("Set the provider as the default.")] public bool Default { get; init; } + + public override ValidationResult Validate() + { + if (Providers.IsSupported(Provider) is false) + { + return ValidationResult.Error($"The provider '{Provider}' is not supported."); + } + + return ValidationResult.Success(); + } } public override async Task ExecuteAsync(CommandContext context, Settings settings) diff --git a/src/AltGen.Console/Config/AppSettings.cs b/src/AltGen.Console/Config/AppSettings.cs index a808f97..3863325 100644 --- a/src/AltGen.Console/Config/AppSettings.cs +++ b/src/AltGen.Console/Config/AppSettings.cs @@ -44,4 +44,19 @@ record AppSettings var updatedProviders = Providers.Where(p => p.Provider != settings.Provider); return this with { Providers = [.. updatedProviders] }; } + + public ProviderSettings[] GetProviders() + { + return Providers; + } + + public ProviderSettings? GetProvider(string provider) + { + return Providers.FirstOrDefault(p => p.Provider == provider); + } + + public ProviderSettings? GetDefaultProvider() + { + return Providers.FirstOrDefault(static p => p.Default); + } } \ No newline at end of file diff --git a/src/AltGen.Console/Config/ListConfigCommand.cs b/src/AltGen.Console/Config/ListConfigCommand.cs index 8f42182..972c638 100644 --- a/src/AltGen.Console/Config/ListConfigCommand.cs +++ b/src/AltGen.Console/Config/ListConfigCommand.cs @@ -19,7 +19,7 @@ sealed class ListConfigCommand( var appSettings = await _settingsManager.GetAppSettingsAsync(); - foreach (var provider in appSettings.Providers) + foreach (var provider in appSettings.GetProviders()) { var providerName = provider.Provider; var providerKey = provider.Key; diff --git a/src/AltGen.Console/Generate/AltGenService.cs b/src/AltGen.Console/Generate/AltGenService.cs index 9576aee..f782c7c 100644 --- a/src/AltGen.Console/Generate/AltGenService.cs +++ b/src/AltGen.Console/Generate/AltGenService.cs @@ -19,7 +19,7 @@ sealed class AltGenService(HttpClient client) : IAltGenService { { new StringContent(req.Provider), "provider" }, { new StringContent(req.ProviderKey), "providerKey" }, - { new ByteArrayContent(req.Image), "file", req.FileName } + { byteContent, "file", req.FileName } } }; @@ -41,4 +41,4 @@ record AltTextResponse(string AltText); class AltTextException(string message) : Exception(message) { -} +} \ No newline at end of file diff --git a/src/AltGen.Console/Generate/GenerateCommand.cs b/src/AltGen.Console/Generate/GenerateCommand.cs index d4b7259..c076088 100644 --- a/src/AltGen.Console/Generate/GenerateCommand.cs +++ b/src/AltGen.Console/Generate/GenerateCommand.cs @@ -3,13 +3,15 @@ namespace AltGen.Console.Generate; sealed class GenerateCommand( IAnsiConsole console, IFileSystem fileSystem, - IAltGenService altGenService + IAltGenService altGenService, + IAppSettingsManager settingsManager ) : AsyncCommand { readonly IAnsiConsole _console = console; readonly IFileSystem _fileSystem = fileSystem; readonly IAltGenService _altGenService = altGenService; + readonly IAppSettingsManager _settingsManager = settingsManager; public class Settings(IFileSystem fileSystem) : CommandSettings { @@ -20,21 +22,17 @@ sealed class GenerateCommand( [".png"] = "image/png" }; - readonly List _providers = [ - "gemini", - ]; - readonly IFileSystem _fileSystem = fileSystem; - [CommandArgument(1, "")] + [CommandOption("-p|--provider")] [Description("The provider to use for generating alt text.")] public string Provider { get; init; } = string.Empty; - [CommandArgument(2, "")] + [CommandOption("-k|--key")] [Description("The key for the provider.")] public string Key { get; init; } = string.Empty; - [CommandArgument(3, "")] + [CommandArgument(1, "")] [Description("The path to the image to generate alt text for.")] public string Path { get; init; } = string.Empty; @@ -42,13 +40,6 @@ sealed class GenerateCommand( public override ValidationResult Validate() { - // TODO: We should allow provider and key to be optional - // if they are not passed on the command line then - // we should try to resolve them from configuration - if (_providers.Contains(Provider) is false) - { - return ValidationResult.Error($"The provider '{Provider}' is not supported."); - } var pathExists = _fileSystem.File.Exists(Path); @@ -70,12 +61,36 @@ sealed class GenerateCommand( public override async Task ExecuteAsync(CommandContext context, Settings settings) { + var appSettings = await _settingsManager.GetAppSettingsAsync(); + var provider = settings.Provider; + var key = settings.Key; + + if (string.IsNullOrWhiteSpace(provider)) + { + var defaultProvider = appSettings.GetDefaultProvider() + ?? throw new AltTextException("Please specify a provider or set a default provider."); + provider = defaultProvider.Provider; + } + + if (Providers.IsSupported(provider) is false) + { + throw new AltTextException($"The provider '{provider}' is not supported."); + } + + if (string.IsNullOrWhiteSpace(key)) + { + var selectedProvider = appSettings.GetProvider(provider) + ?? throw new AltTextException("Please specify a key or set a default provider."); + key = selectedProvider.Key; + } + + var fileName = _fileSystem.Path.GetFileName(settings.Path); var image = await _fileSystem.File.ReadAllBytesAsync(settings.Path); var altText = await _altGenService.GenerateAltTextAsync(new( - settings.Provider, - settings.Key, + provider, + key, fileName, image, settings.ContentType diff --git a/src/AltGen.Console/Generated/Constants.cs b/src/AltGen.Console/Generated/Constants.cs index c9ed236..6066b7a 100644 --- a/src/AltGen.Console/Generated/Constants.cs +++ b/src/AltGen.Console/Generated/Constants.cs @@ -2,5 +2,5 @@ namespace AltGen.Console.Generated; static class Constants { - public const string AltGenApiUri = "http://localhost:7297"; + public const string AltGenApiUri = "https://localhost:7297"; } From c901d8d64320f17e8bfa73dcdf87d88bb88c438e Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 23 Mar 2025 22:33:34 -0500 Subject: [PATCH 14/23] tests: add more test coverage --- src/AltGen.API.Tests/AltGen.API.Tests.csproj | 2 +- .../AltGen.Console.Tests.csproj | 26 +- .../Unit/AddConfigCommandTests.cs | 27 ++ .../Unit/AppSettingsTests.cs | 22 ++ .../Unit/GenerateCommandTests.cs | 320 +++++++++++++++++- .../Unit/HostBuilderExtensionsTests.cs | 14 + .../Unit/TypeRegistrarTests.cs | 74 ++++ .../Unit/TypeResolverTests.cs | 55 +++ src/AltGen.Console.Tests/Usings.cs | 6 +- .../Generate/GenerateCommand.cs | 1 - src/AltGen.Console/Usings.cs | 3 +- 11 files changed, 541 insertions(+), 9 deletions(-) create mode 100644 src/AltGen.Console.Tests/Unit/HostBuilderExtensionsTests.cs create mode 100644 src/AltGen.Console.Tests/Unit/TypeRegistrarTests.cs create mode 100644 src/AltGen.Console.Tests/Unit/TypeResolverTests.cs diff --git a/src/AltGen.API.Tests/AltGen.API.Tests.csproj b/src/AltGen.API.Tests/AltGen.API.Tests.csproj index 4684afd..303cf5b 100644 --- a/src/AltGen.API.Tests/AltGen.API.Tests.csproj +++ b/src/AltGen.API.Tests/AltGen.API.Tests.csproj @@ -16,7 +16,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj b/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj index 401e66c..5ab1b28 100644 --- a/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj +++ b/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj @@ -8,18 +8,40 @@ - + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + - + + + + true + ./TestResults/Coverage/ + cobertura + [AltGen.Console]* + **/Program.cs + + + + + + + diff --git a/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs b/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs index a7af256..f81dc7d 100644 --- a/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs +++ b/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs @@ -11,6 +11,33 @@ public class AddConfigCommandTests : IDisposable _sut = new AddConfigCommand(_testConsole, _mockSettingsManager.Object); } + [Fact] + public void Validate_WhenProviderIsNotSupported_ItShouldReturnError() + { + var commandSettings = new AddConfigCommand.Settings() + { + Provider = "unsupported", + Key = "key", + }; + + var result = commandSettings.Validate(); + + result.Successful.Should().BeFalse(); + } + + [Fact] + public void Validate_WhenProviderIsSupported_ItShouldReturnSuccess() + { + var commandSettings = new AddConfigCommand.Settings() + { + Provider = "gemini", + Key = "key", + }; + + var result = commandSettings.Validate(); + + result.Successful.Should().BeTrue(); + } [Fact] public async Task ExecuteAsync_WhenSettingsDoNotExist_ItShouldCreateSettings() diff --git a/src/AltGen.Console.Tests/Unit/AppSettingsTests.cs b/src/AltGen.Console.Tests/Unit/AppSettingsTests.cs index e50e9ed..ef4dbd5 100644 --- a/src/AltGen.Console.Tests/Unit/AppSettingsTests.cs +++ b/src/AltGen.Console.Tests/Unit/AppSettingsTests.cs @@ -62,6 +62,28 @@ public class AppSettingsTests ])); } + [Fact] + public void AddOrUpdateProvider_WhenExistingProvider_ItShouldAddNewProvider() + { + var providerSettings = new ProviderSettings("claude", "key", false); + var existingProviderSettings = new ProviderSettings("gemini", "key", false); + + var commandSettings = new AddConfigCommand.Settings() + { + Provider = providerSettings.Provider, + Key = providerSettings.Key, + Default = providerSettings.Default, + }; + + var appSettings = new AppSettings([existingProviderSettings]); + var updatedAppSettings = appSettings.AddOrUpdateProvider(commandSettings); + + updatedAppSettings.Should().BeEquivalentTo(new AppSettings([ + providerSettings, + existingProviderSettings, + ])); + } + [Fact] public void RemoveProvider_WhenProviderExists_ItShouldRemoveProvider() { diff --git a/src/AltGen.Console.Tests/Unit/GenerateCommandTests.cs b/src/AltGen.Console.Tests/Unit/GenerateCommandTests.cs index 61905b8..ae5201d 100644 --- a/src/AltGen.Console.Tests/Unit/GenerateCommandTests.cs +++ b/src/AltGen.Console.Tests/Unit/GenerateCommandTests.cs @@ -1,6 +1,324 @@ +using System.Collections; + namespace AltGen.Console.Tests.Unit; -public class GenerateCommandTests +public class GenerateCommandTests : IDisposable { + readonly TestConsole _testConsole = new(); + readonly Mock _mockFileSystem = new(); + readonly Mock _mockAltGenService = new(); + readonly Mock _mockSettingsManager = new(); + readonly GenerateCommand _sut; + public GenerateCommandTests() + { + _sut = new GenerateCommand( + _testConsole, + _mockFileSystem.Object, + _mockAltGenService.Object, + _mockSettingsManager.Object + ); + } + + [Fact] + public void Validate_WhenPathDoesNotExist_ItShouldReturnError() + { + _mockFileSystem + .Setup(static x => x.File.Exists(It.IsAny())) + .Returns(false); + + var settings = new GenerateCommand.Settings(_mockFileSystem.Object) + { + Path = "C:/path/to/image.jpg" + }; + + var result = settings.Validate(); + + result.Successful.Should().BeFalse(); + } + + [Fact] + public void Validate_WhenPathIsNotAnImage_ItShouldReturnError() + { + _mockFileSystem + .Setup(static x => x.File.Exists(It.IsAny())) + .Returns(true); + + _mockFileSystem + .Setup(static x => x.Path.GetExtension(It.IsAny())) + .Returns(".txt"); + + var settings = new GenerateCommand.Settings(_mockFileSystem.Object) + { + Path = "C:/path/to/image.txt" + }; + + var result = settings.Validate(); + + result.Successful.Should().BeFalse(); + } + + [Fact] + public void Validate_WhenPathIsValidImage_ItShouldReturnSuccess() + { + _mockFileSystem + .Setup(static x => x.File.Exists(It.IsAny())) + .Returns(true); + + _mockFileSystem + .Setup(static x => x.Path.GetExtension(It.IsAny())) + .Returns(".jpg"); + + var settings = new GenerateCommand.Settings(_mockFileSystem.Object) + { + Path = "C:/path/to/image.jpg" + }; + + var result = settings.Validate(); + + result.Successful.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteAsync_WhenProviderIsNotProvided_ItShouldThrowException() + { + _mockSettingsManager + .Setup(static x => x.GetAppSettingsAsync()) + .ReturnsAsync(new AppSettings([])); + + var settings = new GenerateCommand.Settings(_mockFileSystem.Object); + + var action = async () => await _sut.ExecuteAsync(null!, settings); + + await action.Should().ThrowAsync(); + } + + [Fact] + public async Task ExecuteAsync_WhenProviderIsProvidedOnCommandLine_ItShouldUseThatProvider() + { + _mockSettingsManager + .Setup(static x => x.GetAppSettingsAsync()) + .ReturnsAsync(new AppSettings([])); + + _mockFileSystem + .Setup(static x => x.Path.GetFileName(It.IsAny())) + .Returns("image.jpg"); + + _mockFileSystem + .Setup(static x => x.File.ReadAllBytesAsync(It.IsAny(), default)) + .ReturnsAsync([0x00]); + + _mockFileSystem + .Setup(static x => x.Path.GetExtension(It.IsAny())) + .Returns(".jpg"); + + _mockAltGenService + .Setup(static x => x.GenerateAltTextAsync(It.IsAny())) + .ReturnsAsync("alt text"); + + var settings = new GenerateCommand.Settings(_mockFileSystem.Object) + { + Provider = "gemini", + Key = "key", + Path = "C:/path/to/image.jpg" + }; + + var result = await _sut.ExecuteAsync(null!, settings); + + result.Should().Be(0); + + _mockAltGenService.Verify( + static x => x.GenerateAltTextAsync(It.Is( + static x => x.Provider == "gemini" && + x.ProviderKey == "key" && + x.FileName == "image.jpg" && + x.Image.Length == 1 && + x.ContentType == "image/jpeg" + )), + Times.Once + ); + } + + [Fact] + public async Task ExecuteAsync_WhenProviderIsNotProvidedOnCommandLine_ItShouldUseDefaultProvider() + { + _mockSettingsManager + .Setup(static x => x.GetAppSettingsAsync()) + .ReturnsAsync(new AppSettings([ + new("gemini", "key", true) + ])); + + _mockFileSystem + .Setup(static x => x.Path.GetFileName(It.IsAny())) + .Returns("image.jpg"); + + _mockFileSystem + .Setup(static x => x.File.ReadAllBytesAsync(It.IsAny(), default)) + .ReturnsAsync([0x00]); + + _mockFileSystem + .Setup(static x => x.Path.GetExtension(It.IsAny())) + .Returns(".jpg"); + + _mockAltGenService + .Setup(static x => x.GenerateAltTextAsync(It.IsAny())) + .ReturnsAsync("alt text"); + + var settings = new GenerateCommand.Settings(_mockFileSystem.Object) + { + Key = "key", + Path = "C:/path/to/image.jpg" + }; + + var result = await _sut.ExecuteAsync(null!, settings); + + result.Should().Be(0); + + _mockAltGenService.Verify( + static x => x.GenerateAltTextAsync(It.Is( + static x => x.Provider == "gemini" && + x.ProviderKey == "key" && + x.FileName == "image.jpg" && + x.Image.Length == 1 && + x.ContentType == "image/jpeg" + )), + Times.Once + ); + } + + [Fact] + public async Task ExecuteAsync_WhenProviderIsNotSupported_ItShouldThrowException() + { + _mockSettingsManager + .Setup(static x => x.GetAppSettingsAsync()) + .ReturnsAsync(new AppSettings([])); + + var settings = new GenerateCommand.Settings(_mockFileSystem.Object) + { + Provider = "unsupported", + Key = "key", + Path = "C:/path/to/image.jpg" + }; + + var action = async () => await _sut.ExecuteAsync(null!, settings); + + await action.Should().ThrowAsync(); + } + + [Fact] + public async Task ExecuteAsync_WhenNoKeyIsProvided_ItShouldThrowException() + { + _mockSettingsManager + .Setup(static x => x.GetAppSettingsAsync()) + .ReturnsAsync(new AppSettings([])); + + var settings = new GenerateCommand.Settings(_mockFileSystem.Object) + { + Provider = "gemini", + Path = "C:/path/to/image.jpg" + }; + + var action = async () => await _sut.ExecuteAsync(null!, settings); + + await action.Should().ThrowAsync(); + } + + [Fact] + public async Task ExecuteAsync_WhenKeyIsProvidedOnCommandLine_ItShouldUseThatKey() + { + _mockSettingsManager + .Setup(static x => x.GetAppSettingsAsync()) + .ReturnsAsync(new AppSettings([])); + + _mockFileSystem + .Setup(static x => x.Path.GetFileName(It.IsAny())) + .Returns("image.jpg"); + + _mockFileSystem + .Setup(static x => x.File.ReadAllBytesAsync(It.IsAny(), default)) + .ReturnsAsync([0x00]); + + _mockFileSystem + .Setup(static x => x.Path.GetExtension(It.IsAny())) + .Returns(".jpg"); + + _mockAltGenService + .Setup(static x => x.GenerateAltTextAsync(It.IsAny())) + .ReturnsAsync("alt text"); + + var settings = new GenerateCommand.Settings(_mockFileSystem.Object) + { + Provider = "gemini", + Key = "key", + Path = "C:/path/to/image.jpg" + }; + + var result = await _sut.ExecuteAsync(null!, settings); + + result.Should().Be(0); + + _mockAltGenService.Verify( + static x => x.GenerateAltTextAsync(It.Is( + static x => x.Provider == "gemini" && + x.ProviderKey == "key" && + x.FileName == "image.jpg" && + x.Image.Length == 1 && + x.ContentType == "image/jpeg" + )), + Times.Once + ); + } + + [Fact] + public async Task ExecuteAsync_WhenNoKeyIsProvidedOnCommandLine_ItShouldUseDefaultKey() + { + _mockSettingsManager + .Setup(static x => x.GetAppSettingsAsync()) + .ReturnsAsync(new AppSettings([ + new("gemini", "key", true) + ])); + + _mockFileSystem + .Setup(static x => x.Path.GetFileName(It.IsAny())) + .Returns("image.jpg"); + + _mockFileSystem + .Setup(static x => x.File.ReadAllBytesAsync(It.IsAny(), default)) + .ReturnsAsync([0x00]); + + _mockFileSystem + .Setup(static x => x.Path.GetExtension(It.IsAny())) + .Returns(".jpg"); + + _mockAltGenService + .Setup(static x => x.GenerateAltTextAsync(It.IsAny())) + .ReturnsAsync("alt text"); + + var settings = new GenerateCommand.Settings(_mockFileSystem.Object) + { + Provider = "gemini", + Path = "C:/path/to/image.jpg" + }; + + var result = await _sut.ExecuteAsync(null!, settings); + + result.Should().Be(0); + + _mockAltGenService.Verify( + static x => x.GenerateAltTextAsync(It.Is( + static x => x.Provider == "gemini" && + x.ProviderKey == "key" && + x.FileName == "image.jpg" && + x.Image.Length == 1 && + x.ContentType == "image/jpeg" + )), + Times.Once + ); + } + + public void Dispose() + { + _testConsole.Dispose(); + GC.SuppressFinalize(this); + } } \ No newline at end of file diff --git a/src/AltGen.Console.Tests/Unit/HostBuilderExtensionsTests.cs b/src/AltGen.Console.Tests/Unit/HostBuilderExtensionsTests.cs new file mode 100644 index 0000000..c126d99 --- /dev/null +++ b/src/AltGen.Console.Tests/Unit/HostBuilderExtensionsTests.cs @@ -0,0 +1,14 @@ +namespace AltGen.Console.Tests.Unit; + +public class HostBuilderExtensionsTests +{ + [Fact] + public void BuildApp_WhenCalled_ItShouldBuildApp() + { + var hostBuilder = new HostBuilder(); + + var app = hostBuilder.BuildApp(); + + app.Should().NotBeNull(); + } +} \ No newline at end of file diff --git a/src/AltGen.Console.Tests/Unit/TypeRegistrarTests.cs b/src/AltGen.Console.Tests/Unit/TypeRegistrarTests.cs new file mode 100644 index 0000000..5269540 --- /dev/null +++ b/src/AltGen.Console.Tests/Unit/TypeRegistrarTests.cs @@ -0,0 +1,74 @@ +namespace AltGen.Console.Tests.Unit; + +public class TypeRegistrarTests +{ + readonly Mock _mockBuilder = new(); + readonly TypeRegistrar _sut; + + public TypeRegistrarTests() + { + _sut = new TypeRegistrar(_mockBuilder.Object); + } + + [Fact] + public void Register_WhenCalled_ItShouldRegisterService() + { + var service = typeof(IService); + var implementation = typeof(Implementation); + + _sut.Register(service, implementation); + + _mockBuilder.Verify(static x => x.ConfigureServices(It.IsAny>()), Times.Once); + } + + [Fact] + public void RegisterInstance_WhenCalled_ItShouldRegisterService() + { + var service = typeof(IService); + var implementation = new Implementation(); + + _sut.RegisterInstance(service, implementation); + + _mockBuilder.Verify(static x => x.ConfigureServices(It.IsAny>()), Times.Once); + } + + [Fact] + public void RegisterLazy_WhenCalled_ItShouldRegisterService() + { + var service = typeof(IService); + + static Implementation Func() + { + return new Implementation(); + } + + _sut.RegisterLazy(service, Func); + + _mockBuilder.Verify(static x => x.ConfigureServices(It.IsAny>()), Times.Once); + } + + [Fact] + public void RegisterLazy_WhenCalledAndFuncIsNull_ItShouldThrowArgumentNullException() + { + var service = typeof(IService); + + var act = () => _sut.RegisterLazy(service, null!); + + act.Should().Throw(); + } + + [Fact] + public void Build_WhenCalled_ItShouldReturnTypeResolver() + { + _mockBuilder.Setup(static x => x.Build()).Returns(Mock.Of()); + + var actual = _sut.Build(); + + actual.Should().BeOfType(); + + _mockBuilder.Verify(static x => x.Build(), Times.Once); + } + + interface IService { } + class Implementation : IService { } +} \ No newline at end of file diff --git a/src/AltGen.Console.Tests/Unit/TypeResolverTests.cs b/src/AltGen.Console.Tests/Unit/TypeResolverTests.cs new file mode 100644 index 0000000..ed23cb4 --- /dev/null +++ b/src/AltGen.Console.Tests/Unit/TypeResolverTests.cs @@ -0,0 +1,55 @@ +namespace AltGen.Console.Tests.Unit; + +public class TypeResolverTests +{ + readonly Mock _mockHost = new(); + + [Fact] + public void Constructor_WhenCalledWithNullHost_ItShouldThrowArgumentNullException() + { + var act = static () => new TypeResolver(null!); + + act.Should().Throw(); + } + + [Fact] + public void Resolve_WhenCalledWithNullType_ItShouldReturnNull() + { + var sut = new TypeResolver(_mockHost.Object); + + var result = sut.Resolve(null); + + result.Should().BeNull(); + } + + [Fact] + public void Resolve_WhenCalledWithValidType_ItShouldReturnService() + { + var sut = new TypeResolver(_mockHost.Object); + var service = typeof(IService); + + _mockHost + .Setup(x => x.Services.GetService(service)) + .Returns(new Implementation()); + + var result = sut.Resolve(service); + + result.Should().NotBeNull(); + + _mockHost.Verify(x => x.Services.GetService(service), Times.Once); + } + + [Fact] + public void Dispose_WhenCalled_ItShouldDisposeHost() + { + var sut = new TypeResolver(_mockHost.Object); + + sut.Dispose(); + + _mockHost.Verify(static x => x.Dispose(), Times.Once); + } + + interface IService { } + + class Implementation : IService { } +} \ No newline at end of file diff --git a/src/AltGen.Console.Tests/Usings.cs b/src/AltGen.Console.Tests/Usings.cs index 83582f6..73aaf61 100644 --- a/src/AltGen.Console.Tests/Usings.cs +++ b/src/AltGen.Console.Tests/Usings.cs @@ -5,11 +5,11 @@ global using AltGen.Console.Common; global using AltGen.Console.Config; global using AltGen.Console.Generate; -global using FluentAssertions; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; global using Moq; global using RichardSzalay.MockHttp; -global using Spectre.Console; -global using Spectre.Console.Testing; +global using Spectre.Console.Testing; \ No newline at end of file diff --git a/src/AltGen.Console/Generate/GenerateCommand.cs b/src/AltGen.Console/Generate/GenerateCommand.cs index c076088..5f918d0 100644 --- a/src/AltGen.Console/Generate/GenerateCommand.cs +++ b/src/AltGen.Console/Generate/GenerateCommand.cs @@ -40,7 +40,6 @@ sealed class GenerateCommand( public override ValidationResult Validate() { - var pathExists = _fileSystem.File.Exists(Path); if (pathExists is false) diff --git a/src/AltGen.Console/Usings.cs b/src/AltGen.Console/Usings.cs index 0782a80..2e74070 100644 --- a/src/AltGen.Console/Usings.cs +++ b/src/AltGen.Console/Usings.cs @@ -1,4 +1,5 @@ global using System.ComponentModel; +global using System.Diagnostics.CodeAnalysis; global using System.IO.Abstractions; global using System.Net.Http.Headers; global using System.Text.Json; @@ -13,4 +14,4 @@ global using Microsoft.Extensions.Hosting; global using Microsoft.Extensions.Logging; global using Spectre.Console; -global using Spectre.Console.Cli; \ No newline at end of file +global using Spectre.Console.Cli; From 23882f0de6e9e8c5ae60aa2e3bdeff5111d81b93 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 23 Mar 2025 22:39:28 -0500 Subject: [PATCH 15/23] chore: stub out console app workflows --- .../{pull_request.yml => api_pull_request.yml} | 2 +- .github/workflows/console_pull_request.yml | 1 + .github/workflows/{deploy.yml => deploy_api.yml} | 12 ++++-------- .github/workflows/publish_console.yml | 1 + 4 files changed, 7 insertions(+), 9 deletions(-) rename .github/workflows/{pull_request.yml => api_pull_request.yml} (98%) create mode 100644 .github/workflows/console_pull_request.yml rename .github/workflows/{deploy.yml => deploy_api.yml} (90%) create mode 100644 .github/workflows/publish_console.yml diff --git a/.github/workflows/pull_request.yml b/.github/workflows/api_pull_request.yml similarity index 98% rename from .github/workflows/pull_request.yml rename to .github/workflows/api_pull_request.yml index 62fcb54..b0338c3 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/api_pull_request.yml @@ -1,4 +1,4 @@ -name: Pull Request +name: API Pull Request defaults: run: working-directory: ./src diff --git a/.github/workflows/console_pull_request.yml b/.github/workflows/console_pull_request.yml new file mode 100644 index 0000000..240d1fb --- /dev/null +++ b/.github/workflows/console_pull_request.yml @@ -0,0 +1 @@ +name: Console Pull Request diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy_api.yml similarity index 90% rename from .github/workflows/deploy.yml rename to .github/workflows/deploy_api.yml index 3270866..86ae7ed 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy_api.yml @@ -1,16 +1,12 @@ -name: Deploy +name: Deploy API on: workflow_dispatch: push: branches: - main - paths-ignore: - - '.github/**' - - '.gitignore' - - '.editorconfig' - - 'LICENSE.md' - - '**/*/README.md' - - '**/*/Dockerfile' + paths: + - src/AltGen.API/** + - src/AltGen.API.Tests/** jobs: build: name: Build and push Docker image diff --git a/.github/workflows/publish_console.yml b/.github/workflows/publish_console.yml new file mode 100644 index 0000000..ed1af98 --- /dev/null +++ b/.github/workflows/publish_console.yml @@ -0,0 +1 @@ +name: Publish Console From b4d00afe7a080509b84064570f532b33a780b848 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 26 Mar 2025 23:52:27 -0500 Subject: [PATCH 16/23] fix: use lower case when creating providers --- src/AltGen.API/Generate/Providers/AltTextProviderFactory.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AltGen.API/Generate/Providers/AltTextProviderFactory.cs b/src/AltGen.API/Generate/Providers/AltTextProviderFactory.cs index caaba7f..8f29bf2 100644 --- a/src/AltGen.API/Generate/Providers/AltTextProviderFactory.cs +++ b/src/AltGen.API/Generate/Providers/AltTextProviderFactory.cs @@ -6,7 +6,7 @@ class AltTextProviderFactory(IServiceProvider serviceProvider) : IAltTextProvide public IAltTextProvider Create(string provider) { - return provider switch + return provider.ToLowerInvariant() switch { LLMProvider.Gemini => _serviceProvider.GetRequiredKeyedService(LLMProvider.Gemini), _ => throw new NotSupportedException($"The provider '{provider}' is not supported.") From f2b80a1e619d969071daf83670a4ddeb50362439 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 26 Mar 2025 23:53:47 -0500 Subject: [PATCH 17/23] chore: update api workflow to account for presence of console projects --- .github/workflows/api_pull_request.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/api_pull_request.yml b/.github/workflows/api_pull_request.yml index b0338c3..abce25e 100644 --- a/.github/workflows/api_pull_request.yml +++ b/.github/workflows/api_pull_request.yml @@ -23,8 +23,10 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: 9.x.x - - name: Format project - run: dotnet format --verbosity normal + - name: Format projects + run: | + dotnet format ./AltGen.API/AltGen.API.csproj --verbosity normal + dotnet format ./AltGen.API.Tests/AltGen.API.Tests.csproj --verbosity normal - name: Commit Changes run: | git config user.name "GitHub Actions" @@ -48,9 +50,9 @@ jobs: with: dotnet-version: 9.x.x - name: Restore dependencies - run: dotnet restore + run: dotnet restore ./AltGen.API.Tests/AltGen.API.Tests.csproj - name: Build project - run: dotnet build --no-restore + run: dotnet build ./AltGen.API.Tests/AltGen.API.Tests.csproj --no-restore - name: Test project - run: dotnet test --filter FullyQualifiedName!~PromptEvaluation + run: dotnet test ./AltGen.API.Tests/AltGen.API.Tests.csproj --filter FullyQualifiedName!~PromptEvaluation From 4b7fde662eb51edcd847ff3350624a9dc946be18 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 26 Mar 2025 23:54:30 -0500 Subject: [PATCH 18/23] chore: add workflow to format, build, and test console projects --- .github/workflows/console_pull_request.yml | 57 ++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/.github/workflows/console_pull_request.yml b/.github/workflows/console_pull_request.yml index 240d1fb..89fe433 100644 --- a/.github/workflows/console_pull_request.yml +++ b/.github/workflows/console_pull_request.yml @@ -1 +1,58 @@ name: Console Pull Request +defaults: + run: + working-directory: ./src +on: + workflow_dispatch: + pull_request: + paths: + - src/AltGen.Console/** + - src/AltGen.Console.Tests/** + branches: + - main +jobs: + format: + name: Run dotnet format + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.x.x + - name: Format projects + run: | + dotnet format ./AltGen.Console/AltGen.Console.csproj --verbosity normal + dotnet format ./AltGen.Console.Tests/AltGen.Console.Tests.csproj --verbosity normal + - name: Commit Changes + run: | + git config user.name "GitHub Actions" + git config user.email "<>" + if [[ $(git status --porcelain) ]]; then + git add . + git commit -m "chore: format fixes [skip ci]" + git fetch origin + git pull --rebase origin ${{ github.head_ref }} + git push origin HEAD:${{ github.head_ref }} + fi + build_and_test: + name: Run tests + needs: format + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.x.x + - name: Restore dependencies + run: dotnet restore ./AltGen.Console.Tests/AltGen.Console.Tests.csproj + - name: Build project + run: dotnet build ./AltGen.Console.Tests/AltGen.Console.Tests.csproj --no-restore + - name: Test project + run: dotnet test ./AltGen.Console.Tests/AltGen.Console.Tests.csproj + From 2c2594d76fad7adc0b3fe5929efa4eb6a82b11f0 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 27 Mar 2025 14:43:56 -0500 Subject: [PATCH 19/23] chore: finish workflows for consoles --- .github/workflows/console_pull_request.yml | 57 ++++++++++++++ .github/workflows/publish_console.yml | 91 ++++++++++++++++++++++ .gitignore | 6 ++ src/AltGen.Console/Generated/Constants.cs | 2 +- 4 files changed, 155 insertions(+), 1 deletion(-) diff --git a/.github/workflows/console_pull_request.yml b/.github/workflows/console_pull_request.yml index 240d1fb..89fe433 100644 --- a/.github/workflows/console_pull_request.yml +++ b/.github/workflows/console_pull_request.yml @@ -1 +1,58 @@ name: Console Pull Request +defaults: + run: + working-directory: ./src +on: + workflow_dispatch: + pull_request: + paths: + - src/AltGen.Console/** + - src/AltGen.Console.Tests/** + branches: + - main +jobs: + format: + name: Run dotnet format + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.x.x + - name: Format projects + run: | + dotnet format ./AltGen.Console/AltGen.Console.csproj --verbosity normal + dotnet format ./AltGen.Console.Tests/AltGen.Console.Tests.csproj --verbosity normal + - name: Commit Changes + run: | + git config user.name "GitHub Actions" + git config user.email "<>" + if [[ $(git status --porcelain) ]]; then + git add . + git commit -m "chore: format fixes [skip ci]" + git fetch origin + git pull --rebase origin ${{ github.head_ref }} + git push origin HEAD:${{ github.head_ref }} + fi + build_and_test: + name: Run tests + needs: format + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.x.x + - name: Restore dependencies + run: dotnet restore ./AltGen.Console.Tests/AltGen.Console.Tests.csproj + - name: Build project + run: dotnet build ./AltGen.Console.Tests/AltGen.Console.Tests.csproj --no-restore + - name: Test project + run: dotnet test ./AltGen.Console.Tests/AltGen.Console.Tests.csproj + diff --git a/.github/workflows/publish_console.yml b/.github/workflows/publish_console.yml index ed1af98..34afcd6 100644 --- a/.github/workflows/publish_console.yml +++ b/.github/workflows/publish_console.yml @@ -1 +1,92 @@ name: Publish Console +on: + workflow_dispatch: + push: + paths: + - src/AltGen.Console/** + - src/AltGen.Console.Tests/** + branches: + - main +jobs: + version: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.ACTIONS_PAT }} + - name: Setup .NET 9 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.x + - name: Install versionize + run: dotnet tool install --global Versionize + - name: Setup git + run: | + git config --local user.email "65925598+StevanFreeborn@users.noreply.github.com" + git config --local user.name "Stevan Freeborn" + - name: Run versionize + id: versionize + run: versionize -i --exit-insignificant-commits --workingDir ./src/AltGen.Console --commit-suffix "[skip ci]" + continue-on-error: true + - name: Upload changelog + if: steps.versionize.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: change-log + path: src/BGR.Console/CHANGELOG.md + - name: Push changes to GitHub + if: steps.versionize.outcome == 'success' + uses: ad-m/github-push-action@master + with: + github_token: ${{ secrets.ACTIONS_PAT }} + branch: ${{ github.ref }} + tags: true + outputs: + is_new_version: ${{ steps.versionize.outcome == 'success' }} + publish: + needs: [version] + if: needs.version.outputs.is_new_version == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.ref }} + token: ${{ secrets.ACTIONS_PAT }} + - name: Setup .NET 9 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.x + - name: Build + run: dotnet build src + - name: Publish for mac-os + run: dotnet publish src/AltGen.Console/AltGen.Console.csproj -c Release -r osx-x64 --self-contained -o dist/mac-os -p:AltGenApiUri=${{ secrets.ALTGEN_API_URI }} + - name: Publish for linux-os + run: dotnet publish src/AltGen.Console/AltGen.Console.csproj -c Release -r linux-x64 --self-contained -o dist/linux-os -p:AltGenApiUri=${{ secrets.ALTGEN_API_URI }} + - name: Publish for windows-os + run: dotnet publish src/AltGen.Console/AltGen.Console.csproj -c Release -r win-x64 --self-contained -o dist/windows-os -p:AltGenApiUri=${{ secrets.ALTGEN_API_URI }} + - name: Get project version + uses: kzrnm/get-net-sdk-project-versions-action@v1 + id: get-version + with: + proj-path: src/AltGen.Console/AltGen.Console.csproj + - name: Download changlog + uses: actions/download-artifact@v4 + with: + name: change-log + path: src/AltGen.Console + - name: Create release + uses: softprops/action-gh-release@v1 + with: + token: ${{ secrets.ACTIONS_PAT }} + name: bgr v${{ steps.get-version.outputs.version }} + tag_name: v${{ steps.get-version.outputs.version }} + draft: false + body_path: src/AltGen.Console/CHANGELOG.md + files: | + dist/mac-os/AltGen.Console + dist/linux-os/AltGen.Console + dist/windows-os/AltGen.Console.exe diff --git a/.gitignore b/.gitignore index fdb040a..d35eb32 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,12 @@ appsettings*.json !appsettings.Example.json +# generated +src/AltGen.Console/Generated/**/* + +# dist +dist/**/* + # dotenv files .env diff --git a/src/AltGen.Console/Generated/Constants.cs b/src/AltGen.Console/Generated/Constants.cs index 6066b7a..4d14333 100644 --- a/src/AltGen.Console/Generated/Constants.cs +++ b/src/AltGen.Console/Generated/Constants.cs @@ -3,4 +3,4 @@ namespace AltGen.Console.Generated; static class Constants { public const string AltGenApiUri = "https://localhost:7297"; -} +} \ No newline at end of file From 47377002e0a2c84e43a03519f1e4f89a44710a35 Mon Sep 17 00:00:00 2001 From: GitHub Actions <> Date: Thu, 27 Mar 2025 19:45:09 +0000 Subject: [PATCH 20/23] chore: format fixes [skip ci] --- src/AltGen.Console/Config/ProviderSettings.cs | 2 +- src/AltGen.Console/Generate/GenerateAltTextRequest.cs | 2 +- src/AltGen.Console/Usings.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AltGen.Console/Config/ProviderSettings.cs b/src/AltGen.Console/Config/ProviderSettings.cs index acec697..df54ab8 100644 --- a/src/AltGen.Console/Config/ProviderSettings.cs +++ b/src/AltGen.Console/Config/ProviderSettings.cs @@ -1,3 +1,3 @@ namespace AltGen.Console.Config; -record ProviderSettings(string Provider, string Key, bool Default); +record ProviderSettings(string Provider, string Key, bool Default); \ No newline at end of file diff --git a/src/AltGen.Console/Generate/GenerateAltTextRequest.cs b/src/AltGen.Console/Generate/GenerateAltTextRequest.cs index b1f432f..fe7d771 100644 --- a/src/AltGen.Console/Generate/GenerateAltTextRequest.cs +++ b/src/AltGen.Console/Generate/GenerateAltTextRequest.cs @@ -6,4 +6,4 @@ record GenerateAltTextRequest( string FileName, byte[] Image, string ContentType -); +); \ No newline at end of file diff --git a/src/AltGen.Console/Usings.cs b/src/AltGen.Console/Usings.cs index 2e74070..53b16c9 100644 --- a/src/AltGen.Console/Usings.cs +++ b/src/AltGen.Console/Usings.cs @@ -14,4 +14,4 @@ global using Microsoft.Extensions.Hosting; global using Microsoft.Extensions.Logging; global using Spectre.Console; -global using Spectre.Console.Cli; +global using Spectre.Console.Cli; \ No newline at end of file From f8aefb82abacc66fd97e008877c6eb07b272b2ed Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Fri, 28 Mar 2025 18:14:09 -0500 Subject: [PATCH 21/23] fix: address issues causing problems in workflows --- .github/workflows/api_pull_request.yml | 8 +++++++- .github/workflows/console_pull_request.yml | 8 +++++++- .github/workflows/deploy_api.yml | 2 +- src/AltGen.API.Tests/AltGen.API.Tests.csproj | 8 ++++---- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/workflows/api_pull_request.yml b/.github/workflows/api_pull_request.yml index abce25e..f805848 100644 --- a/.github/workflows/api_pull_request.yml +++ b/.github/workflows/api_pull_request.yml @@ -49,10 +49,16 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: 9.x.x + - name: Install report generator + run: dotnet tool install --global dotnet-reportgenerator-globaltool - name: Restore dependencies run: dotnet restore ./AltGen.API.Tests/AltGen.API.Tests.csproj - name: Build project run: dotnet build ./AltGen.API.Tests/AltGen.API.Tests.csproj --no-restore - name: Test project run: dotnet test ./AltGen.API.Tests/AltGen.API.Tests.csproj --filter FullyQualifiedName!~PromptEvaluation - + - name: Upload test coverage report + uses: actions/upload-artifact@v4 + with: + name: test-coverage + path: ./AltGen.API.Tests/TestResults diff --git a/.github/workflows/console_pull_request.yml b/.github/workflows/console_pull_request.yml index 89fe433..10cbea3 100644 --- a/.github/workflows/console_pull_request.yml +++ b/.github/workflows/console_pull_request.yml @@ -49,10 +49,16 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: 9.x.x + - name: Install report generator + run: dotnet tool install --global dotnet-reportgenerator-globaltool - name: Restore dependencies run: dotnet restore ./AltGen.Console.Tests/AltGen.Console.Tests.csproj - name: Build project run: dotnet build ./AltGen.Console.Tests/AltGen.Console.Tests.csproj --no-restore - name: Test project run: dotnet test ./AltGen.Console.Tests/AltGen.Console.Tests.csproj - + - name: Upload test coverage report + uses: actions/upload-artifact@v4 + with: + name: test-coverage + path: ./AltGen.Console.Tests/TestResults diff --git a/.github/workflows/deploy_api.yml b/.github/workflows/deploy_api.yml index 86ae7ed..f4c33d3 100644 --- a/.github/workflows/deploy_api.yml +++ b/.github/workflows/deploy_api.yml @@ -47,5 +47,5 @@ jobs: docker stop api.altgen.stevanfreeborn.com docker rm api.altgen.stevanfreeborn.com docker pull ${{ secrets.DOCKERHUB_USERNAME }}/api.altgen.stevanfreeborn.com:${{ needs.build.outputs.version }} - docker run -d -p 7778:8080 --name api.altgen.stevanfreeborn.com ${{ secrets.DOCKERHUB_USERNAME }}/api.altgen.stevanfreeborn.com:${{ needs.build.outputs.version }} + docker run --restart always -d -p 7778:8080 --name api.altgen.stevanfreeborn.com ${{ secrets.DOCKERHUB_USERNAME }}/api.altgen.stevanfreeborn.com:${{ needs.build.outputs.version }} diff --git a/src/AltGen.API.Tests/AltGen.API.Tests.csproj b/src/AltGen.API.Tests/AltGen.API.Tests.csproj index 303cf5b..f5fd392 100644 --- a/src/AltGen.API.Tests/AltGen.API.Tests.csproj +++ b/src/AltGen.API.Tests/AltGen.API.Tests.csproj @@ -46,12 +46,12 @@ - + Always - - + + Always - + From 9dc43151b13687b30d2d78feb2b86b1590505a16 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Fri, 28 Mar 2025 18:19:55 -0500 Subject: [PATCH 22/23] fix: skip test settings if not present --- src/AltGen.API.Tests/AltGen.API.Tests.csproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/AltGen.API.Tests/AltGen.API.Tests.csproj b/src/AltGen.API.Tests/AltGen.API.Tests.csproj index f5fd392..6e00a1c 100644 --- a/src/AltGen.API.Tests/AltGen.API.Tests.csproj +++ b/src/AltGen.API.Tests/AltGen.API.Tests.csproj @@ -46,12 +46,12 @@ - + Always - - + + Always - + From cc995c911eec94aa4f6172f425ff337edb93d1d9 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Fri, 28 Mar 2025 18:29:23 -0500 Subject: [PATCH 23/23] fix: add gemini key as env var in workflow and make config file optional --- .github/workflows/api_pull_request.yml | 2 ++ src/AltGen.API.Tests/Fixtures/TestConfiguration.cs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/api_pull_request.yml b/.github/workflows/api_pull_request.yml index f805848..8bb9a09 100644 --- a/.github/workflows/api_pull_request.yml +++ b/.github/workflows/api_pull_request.yml @@ -56,6 +56,8 @@ jobs: - name: Build project run: dotnet build ./AltGen.API.Tests/AltGen.API.Tests.csproj --no-restore - name: Test project + env: + Gemini__ApiKey: ${{ secrets.GEMINI_APIKEY }} run: dotnet test ./AltGen.API.Tests/AltGen.API.Tests.csproj --filter FullyQualifiedName!~PromptEvaluation - name: Upload test coverage report uses: actions/upload-artifact@v4 diff --git a/src/AltGen.API.Tests/Fixtures/TestConfiguration.cs b/src/AltGen.API.Tests/Fixtures/TestConfiguration.cs index a09be22..8994f34 100644 --- a/src/AltGen.API.Tests/Fixtures/TestConfiguration.cs +++ b/src/AltGen.API.Tests/Fixtures/TestConfiguration.cs @@ -5,7 +5,7 @@ namespace AltGen.API.Tests.Fixtures; public class TestConfiguration { static IConfiguration Config { get; } = new ConfigurationBuilder() - .AddJsonFile("appsettings.Test.json") + .AddJsonFile("appsettings.Test.json", optional: true) .AddEnvironmentVariables() .Build();