diff --git a/.github/workflows/pull_request.yml b/.github/workflows/api_pull_request.yml similarity index 59% rename from .github/workflows/pull_request.yml rename to .github/workflows/api_pull_request.yml index 62fcb54..8bb9a09 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 @@ -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" @@ -47,10 +49,18 @@ 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 + 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 - + 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 + 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 new file mode 100644 index 0000000..10cbea3 --- /dev/null +++ b/.github/workflows/console_pull_request.yml @@ -0,0 +1,64 @@ +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: 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.yml b/.github/workflows/deploy_api.yml similarity index 81% rename from .github/workflows/deploy.yml rename to .github/workflows/deploy_api.yml index 3270866..f4c33d3 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 @@ -51,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/.github/workflows/publish_console.yml b/.github/workflows/publish_console.yml new file mode 100644 index 0000000..34afcd6 --- /dev/null +++ b/.github/workflows/publish_console.yml @@ -0,0 +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/.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 diff --git a/src/AltGen.API.Tests/AltGen.API.Tests.csproj b/src/AltGen.API.Tests/AltGen.API.Tests.csproj index 4684afd..6e00a1c 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 - + @@ -49,7 +49,7 @@ Always - + Always 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(); 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.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.") 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 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..5ab1b28 --- /dev/null +++ b/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj @@ -0,0 +1,51 @@ + + + + net9.0 + enable + enable + false + + + + + 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 new file mode 100644 index 0000000..f81dc7d --- /dev/null +++ b/src/AltGen.Console.Tests/Unit/AddConfigCommandTests.cs @@ -0,0 +1,118 @@ +namespace AltGen.Console.Tests.Unit; + +public class AddConfigCommandTests : IDisposable +{ + readonly Mock _mockSettingsManager = new(); + readonly TestConsole _testConsole = new(); + readonly AddConfigCommand _sut; + + public AddConfigCommandTests() + { + _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() + { + var expectedAppSettings = new AppSettings([ + new("provider", "key", false) + ]); + + _mockSettingsManager + .Setup(static x => x.AppSettingsExist()) + .Returns(false); + + var commandSettings = new AddConfigCommand.Settings() + { + Provider = "provider", + Key = "key", + }; + + var result = await _sut.ExecuteAsync(null!, commandSettings); + + result.Should().Be(0); + + _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 existingAppSettings = new AppSettings([ + new("existing", "existing", false) + ]); + + var expectedAppSettings = new AppSettings([ + new("existing", "key", true) + ]); + + _mockSettingsManager + .Setup(static x => x.AppSettingsExist()) + .Returns(true); + + _mockSettingsManager + .Setup(static x => x.GetAppSettingsAsync()) + .ReturnsAsync(existingAppSettings); + + var commandSettings = new AddConfigCommand.Settings() + { + Provider = "existing", + Key = "key", + Default = true, + }; + + var result = await _sut.ExecuteAsync(null!, commandSettings); + + result.Should().Be(0); + + _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() + { + _testConsole.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file 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/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 new file mode 100644 index 0000000..ef4dbd5 --- /dev/null +++ b/src/AltGen.Console.Tests/Unit/AppSettingsTests.cs @@ -0,0 +1,103 @@ +namespace AltGen.Console.Tests.Unit; + +public class AppSettingsTests +{ + [Fact] + public void AddOrUpdateProvider_WhenProviderDoesNotExist_ItShouldAddProvider() + { + var providerSettings = new ProviderSettings("provider", "key", true); + + var commandSettings = new AddConfigCommand.Settings() + { + 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() + { + 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() + { + 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 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() + { + var existingProviderSettings = new ProviderSettings("provider", "key", true); + + var commandSettings = new RemoveConfigCommand.Settings() + { + 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/GenerateCommandTests.cs b/src/AltGen.Console.Tests/Unit/GenerateCommandTests.cs new file mode 100644 index 0000000..ae5201d --- /dev/null +++ b/src/AltGen.Console.Tests/Unit/GenerateCommandTests.cs @@ -0,0 +1,324 @@ +using System.Collections; + +namespace AltGen.Console.Tests.Unit; + +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/ListConfigCommandTests.cs b/src/AltGen.Console.Tests/Unit/ListConfigCommandTests.cs new file mode 100644 index 0000000..d3120fd --- /dev/null +++ b/src/AltGen.Console.Tests/Unit/ListConfigCommandTests.cs @@ -0,0 +1,86 @@ +namespace AltGen.Console.Tests.Unit; + +public class ListConfigCommandTests : IDisposable +{ + readonly Mock _mockSettingsManager = new(); + readonly TestConsole _testConsole = new(); + readonly ListConfigCommand _sut; + + public ListConfigCommandTests() + { + _sut = new ListConfigCommand(_testConsole, _mockSettingsManager.Object); + } + + [Fact] + public async Task ExecuteAsync_WhenSettingsDoNotExist_ItShouldOutputNoSettings() + { + _mockSettingsManager + .Setup(static x => x.AppSettingsExist()) + .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 testSettings = new AppSettings([ + new("provider", "key", false) + ]); + + _mockSettingsManager + .Setup(static x => x.AppSettingsExist()) + .Returns(true); + + _mockSettingsManager + .Setup(static x => x.GetAppSettingsAsync()) + .ReturnsAsync(testSettings); + + var result = await _sut.ExecuteAsync(null!); + + result.Should().Be(0); + + _testConsole + .Output + .Should() + .Contain("provider key"); + } + + [Fact] + public async Task ExecuteAsync_WhenSettingsExistAndDefault_ItShouldOutputSettings() + { + var testSettings = new AppSettings([ + new("provider", "key", true) + ]); + + _mockSettingsManager + .Setup(static x => x.AppSettingsExist()) + .Returns(true); + + _mockSettingsManager + .Setup(static x => x.GetAppSettingsAsync()) + .ReturnsAsync(testSettings); + + var result = await _sut.ExecuteAsync(null!); + + result.Should().Be(0); + + _testConsole + .Output + .Should() + .Contain("provider key (default)"); + } + + 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 new file mode 100644 index 0000000..e49c1ee --- /dev/null +++ b/src/AltGen.Console.Tests/Unit/RemoveConfigCommandTests.cs @@ -0,0 +1,91 @@ +namespace AltGen.Console.Tests.Unit; + +public class RemoveConfigCommandTests : IDisposable +{ + readonly Mock _mockSettingsManager = new(); + readonly TestConsole _testConsole = new(); + readonly RemoveConfigCommand _sut; + + public RemoveConfigCommandTests() + { + _sut = new RemoveConfigCommand(_testConsole, _mockSettingsManager.Object); + } + + [Fact] + public async Task ExecuteAsync_WhenSettingsDoNotExist_ItShouldThrow() + { + _mockSettingsManager + .Setup(static x => x.AppSettingsExist()) + .Returns(false); + + var commandSettings = new RemoveConfigCommand.Settings() + { + Provider = "provider", + }; + + var action = async () => await _sut.ExecuteAsync(null!, commandSettings); + + await action.Should().ThrowAsync(); + } + + + [Fact] + public async Task ExecuteAsync_WhenProviderDoesNotExist_ItShouldDoNothing() + { + var testSettings = new AppSettings([]); + + _mockSettingsManager + .Setup(static x => x.AppSettingsExist()) + .Returns(true); + + _mockSettingsManager + .Setup(static x => x.GetAppSettingsAsync()) + .ReturnsAsync(testSettings); + + var commandSettings = new RemoveConfigCommand.Settings() + { + Provider = "provider", + }; + + var result = await _sut.ExecuteAsync(null!, commandSettings); + + result.Should().Be(0); + + _mockSettingsManager.Verify(x => x.SaveAppSettingsAsync(testSettings), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_WhenProviderExists_ItShouldRemoveProviderFromSettings() + { + var testSettings = new AppSettings([ + new("provider", "key", false) + ]); + + var expectedSettings = new AppSettings([]); + + _mockSettingsManager + .Setup(static x => x.AppSettingsExist()) + .Returns(true); + + _mockSettingsManager + .Setup(static x => x.GetAppSettingsAsync()) + .ReturnsAsync(testSettings); + + var commandSettings = new RemoveConfigCommand.Settings() + { + Provider = "provider", + }; + + var result = await _sut.ExecuteAsync(null!, commandSettings); + + result.Should().Be(0); + + _mockSettingsManager.Verify(x => x.SaveAppSettingsAsync(expectedSettings), Times.Once); + } + + public void Dispose() + { + _testConsole.Dispose(); + GC.SuppressFinalize(this); + } +} \ 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 new file mode 100644 index 0000000..73aaf61 --- /dev/null +++ b/src/AltGen.Console.Tests/Usings.cs @@ -0,0 +1,15 @@ +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; + +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; + +global using Moq; + +global using RichardSzalay.MockHttp; + +global using Spectre.Console.Testing; \ No newline at end of file diff --git a/src/AltGen.Console/AltGen.Console.csproj b/src/AltGen.Console/AltGen.Console.csproj new file mode 100644 index 0000000..f4841cf --- /dev/null +++ b/src/AltGen.Console/AltGen.Console.csproj @@ -0,0 +1,50 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + + + + + + + + Generated + $(GeneratedCodeDirectory)\Constants.cs + https://localhost:7297 + + + + + + + + + + + + + + diff --git a/src/AltGen.Console/Common/HostBuilderExtensions.cs b/src/AltGen.Console/Common/HostBuilderExtensions.cs new file mode 100644 index 0000000..b1077e2 --- /dev/null +++ b/src/AltGen.Console/Common/HostBuilderExtensions.cs @@ -0,0 +1,29 @@ +namespace AltGen.Console.Common; + +static class HostBuilderExtensions +{ + public static CommandApp BuildApp(this IHostBuilder builder) + { + var registrar = new TypeRegistrar(builder); + var app = new CommandApp(registrar); + + 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"); + c.AddCommand("remove"); + c.AddCommand("list"); + }); + }); + + return app; + } +} \ No newline at end of file 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/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/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/Config/AddConfigCommand.cs b/src/AltGen.Console/Config/AddConfigCommand.cs new file mode 100644 index 0000000..277b947 --- /dev/null +++ b/src/AltGen.Console/Config/AddConfigCommand.cs @@ -0,0 +1,54 @@ +namespace AltGen.Console.Config; + +sealed class AddConfigCommand( + IAnsiConsole console, + IAppSettingsManager settingsManager +) : AsyncCommand +{ + readonly IAnsiConsole _console = console; + readonly IAppSettingsManager _settingsManager = settingsManager; + + public class Settings : CommandSettings + { + [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 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) + { + if (_settingsManager.AppSettingsExist() is false) + { + var appSettings = new AppSettings([ + new(settings.Provider, settings.Key, settings.Default) + ]); + await _settingsManager.SaveAppSettingsAsync(appSettings); + _console.MarkupLine($"[bold]{settings.Provider}[/] has been configured."); + return 0; + } + + var existingAppSettings = await _settingsManager.GetAppSettingsAsync(); + var updatedAppSettings = existingAppSettings.AddOrUpdateProvider(settings); + await _settingsManager.SaveAppSettingsAsync(updatedAppSettings); + _console.MarkupLine($"[bold]{settings.Provider}[/] has been configured."); + return 0; + } +} \ 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..3863325 --- /dev/null +++ b/src/AltGen.Console/Config/AppSettings.cs @@ -0,0 +1,62 @@ +namespace AltGen.Console.Config; + +record AppSettings +{ + public const string SettingsFileName = "appsettings.json"; + + public ProviderSettings[] Providers { get; init; } = []; + + public AppSettings(ProviderSettings[] providers) + { + Providers = 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] }; + } + + 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/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/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/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 new file mode 100644 index 0000000..972c638 --- /dev/null +++ b/src/AltGen.Console/Config/ListConfigCommand.cs @@ -0,0 +1,32 @@ + +namespace AltGen.Console.Config; + +sealed class ListConfigCommand( + IAnsiConsole console, + IAppSettingsManager settingsManager +) : AsyncCommand +{ + readonly IAnsiConsole _console = console; + readonly IAppSettingsManager _settingsManager = settingsManager; + + public override async Task ExecuteAsync(CommandContext context) + { + if (_settingsManager.AppSettingsExist() is false) + { + _console.MarkupLine("No settings found."); + return 0; + } + + var appSettings = await _settingsManager.GetAppSettingsAsync(); + + foreach (var provider in appSettings.GetProviders()) + { + 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/ProviderSettings.cs b/src/AltGen.Console/Config/ProviderSettings.cs new file mode 100644 index 0000000..df54ab8 --- /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); \ No newline at end of file diff --git a/src/AltGen.Console/Config/RemoveConfigCommand.cs b/src/AltGen.Console/Config/RemoveConfigCommand.cs new file mode 100644 index 0000000..925f408 --- /dev/null +++ b/src/AltGen.Console/Config/RemoveConfigCommand.cs @@ -0,0 +1,31 @@ +namespace AltGen.Console.Config; + +sealed class RemoveConfigCommand( + IAnsiConsole console, + IAppSettingsManager settingsManager +) : AsyncCommand +{ + readonly IAnsiConsole _console = console; + readonly IAppSettingsManager _settingsManager = settingsManager; + + 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) + { + if (_settingsManager.AppSettingsExist() is false) + { + throw new ConfigException("No existing settings found."); + } + + var appSettings = await _settingsManager.GetAppSettingsAsync(); + var updatedAppSettings = appSettings.RemoveProvider(settings); + await _settingsManager.SaveAppSettingsAsync(updatedAppSettings); + _console.MarkupLine($"[bold]{settings.Provider}[/] has been removed."); + return 0; + } +} \ No newline at end of file diff --git a/src/AltGen.Console/Generate/AltGenService.cs b/src/AltGen.Console/Generate/AltGenService.cs new file mode 100644 index 0000000..f782c7c --- /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" }, + { byteContent, "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) +{ +} \ No newline at end of file diff --git a/src/AltGen.Console/Generate/GenerateAltTextRequest.cs b/src/AltGen.Console/Generate/GenerateAltTextRequest.cs new file mode 100644 index 0000000..fe7d771 --- /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 +); \ No newline at end of file diff --git a/src/AltGen.Console/Generate/GenerateCommand.cs b/src/AltGen.Console/Generate/GenerateCommand.cs new file mode 100644 index 0000000..5f918d0 --- /dev/null +++ b/src/AltGen.Console/Generate/GenerateCommand.cs @@ -0,0 +1,102 @@ +namespace AltGen.Console.Generate; + +sealed class GenerateCommand( + IAnsiConsole console, + IFileSystem fileSystem, + 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 + { + readonly Dictionary _imageTypes = new() + { + [".jpeg"] = "image/jpeg", + [".jpg"] = "image/jpeg", + [".png"] = "image/png" + }; + + readonly IFileSystem _fileSystem = fileSystem; + + [CommandOption("-p|--provider")] + [Description("The provider to use for generating alt text.")] + public string Provider { get; init; } = string.Empty; + + [CommandOption("-k|--key")] + [Description("The key for the provider.")] + public string Key { get; init; } = string.Empty; + + [CommandArgument(1, "")] + [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() + { + 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 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( + provider, + 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..81101a0 --- /dev/null +++ b/src/AltGen.Console/Generate/IAltGenService.cs @@ -0,0 +1,4 @@ +interface IAltGenService +{ + Task GenerateAltTextAsync(GenerateAltTextRequest req); +} \ No newline at end of file diff --git a/src/AltGen.Console/Generated/Constants.cs b/src/AltGen.Console/Generated/Constants.cs new file mode 100644 index 0000000..4d14333 --- /dev/null +++ b/src/AltGen.Console/Generated/Constants.cs @@ -0,0 +1,6 @@ +namespace AltGen.Console.Generated; + +static class Constants +{ + public const string AltGenApiUri = "https://localhost:7297"; +} \ 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..cc4380e --- /dev/null +++ b/src/AltGen.Console/Program.cs @@ -0,0 +1,12 @@ +await Host.CreateDefaultBuilder(args) + .ConfigureLogging(static l => l.ClearProviders()) + .ConfigureServices(static (_, services) => + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(AnsiConsole.Console); + services.AddHttpClient() + .AddStandardResilienceHandler(); + }) + .BuildApp() + .RunAsync(args); \ 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..53b16c9 --- /dev/null +++ b/src/AltGen.Console/Usings.cs @@ -0,0 +1,17 @@ +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; + +global using AltGen.Console.Common; +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; \ No newline at end of file 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