Merge pull request #1 from StevanFreeborn/stevanfreeborn/feat/add-console-app

feat: add console app
This commit is contained in:
Stevan Freeborn
2025-03-28 18:31:22 -05:00
committed by GitHub
46 changed files with 1989 additions and 22 deletions
@@ -1,4 +1,4 @@
name: Pull Request name: API Pull Request
defaults: defaults:
run: run:
working-directory: ./src working-directory: ./src
@@ -23,8 +23,10 @@ jobs:
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 9.x.x dotnet-version: 9.x.x
- name: Format project - name: Format projects
run: dotnet format --verbosity normal run: |
dotnet format ./AltGen.API/AltGen.API.csproj --verbosity normal
dotnet format ./AltGen.API.Tests/AltGen.API.Tests.csproj --verbosity normal
- name: Commit Changes - name: Commit Changes
run: | run: |
git config user.name "GitHub Actions" git config user.name "GitHub Actions"
@@ -47,10 +49,18 @@ jobs:
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 9.x.x dotnet-version: 9.x.x
- name: Install report generator
run: dotnet tool install --global dotnet-reportgenerator-globaltool
- name: Restore dependencies - name: Restore dependencies
run: dotnet restore run: dotnet restore ./AltGen.API.Tests/AltGen.API.Tests.csproj
- name: Build project - name: Build project
run: dotnet build --no-restore run: dotnet build ./AltGen.API.Tests/AltGen.API.Tests.csproj --no-restore
- name: Test project - 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
@@ -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
@@ -1,16 +1,12 @@
name: Deploy name: Deploy API
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: branches:
- main - main
paths-ignore: paths:
- '.github/**' - src/AltGen.API/**
- '.gitignore' - src/AltGen.API.Tests/**
- '.editorconfig'
- 'LICENSE.md'
- '**/*/README.md'
- '**/*/Dockerfile'
jobs: jobs:
build: build:
name: Build and push Docker image name: Build and push Docker image
@@ -51,5 +47,5 @@ jobs:
docker stop api.altgen.stevanfreeborn.com docker stop api.altgen.stevanfreeborn.com
docker rm api.altgen.stevanfreeborn.com docker rm api.altgen.stevanfreeborn.com
docker pull ${{ secrets.DOCKERHUB_USERNAME }}/api.altgen.stevanfreeborn.com:${{ needs.build.outputs.version }} 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 }}
+92
View File
@@ -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
+6
View File
@@ -7,6 +7,12 @@
appsettings*.json appsettings*.json
!appsettings.Example.json !appsettings.Example.json
# generated
src/AltGen.Console/Generated/**/*
# dist
dist/**/*
# dotenv files # dotenv files
.env .env
+49
View File
@@ -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
}
]
}
+41
View File
@@ -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"
}
]
}
+2 -2
View File
@@ -16,7 +16,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="FluentAssertions" Version="7.1.0" /> <PackageReference Include="FluentAssertions" Version="[7.1.0]" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.1" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="RichardSzalay.MockHttp" Version="7.0.0" /> <PackageReference Include="RichardSzalay.MockHttp" Version="7.0.0" />
@@ -49,7 +49,7 @@
<Content Include="Files\**\*"> <Content Include="Files\**\*">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </Content>
<Content Include="appsettings.Test.json"> <Content Include="appsettings.Test.json" Condition="Exists('appsettings.Test.json')">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </Content>
</ItemGroup> </ItemGroup>
@@ -5,7 +5,7 @@ namespace AltGen.API.Tests.Fixtures;
public class TestConfiguration public class TestConfiguration
{ {
static IConfiguration Config { get; } = new ConfigurationBuilder() static IConfiguration Config { get; } = new ConfigurationBuilder()
.AddJsonFile("appsettings.Test.json") .AddJsonFile("appsettings.Test.json", optional: true)
.AddEnvironmentVariables() .AddEnvironmentVariables()
.Build(); .Build();
+1
View File
@@ -11,6 +11,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
<InternalsVisibleTo Include="$(AssemblyName).Tests" /> <InternalsVisibleTo Include="$(AssemblyName).Tests" />
</ItemGroup> </ItemGroup>
@@ -6,7 +6,7 @@ class AltTextProviderFactory(IServiceProvider serviceProvider) : IAltTextProvide
public IAltTextProvider Create(string provider) public IAltTextProvider Create(string provider)
{ {
return provider switch return provider.ToLowerInvariant() switch
{ {
LLMProvider.Gemini => _serviceProvider.GetRequiredKeyedService<IAltTextProvider>(LLMProvider.Gemini), LLMProvider.Gemini => _serviceProvider.GetRequiredKeyedService<IAltTextProvider>(LLMProvider.Gemini),
_ => throw new NotSupportedException($"The provider '{provider}' is not supported.") _ => throw new NotSupportedException($"The provider '{provider}' is not supported.")
@@ -2,10 +2,10 @@ namespace AltGen.API.Generate.Providers;
static class LLMProvider static class LLMProvider
{ {
public const string Gemini = "Gemini"; public const string Gemini = "gemini";
public static bool IsValid(string provider) public static bool IsValid(string provider)
{ {
return provider is Gemini; return provider.ToLowerInvariant() is Gemini;
} }
} }
@@ -0,0 +1,51 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.msbuild" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Spectre.Console.Testing" Version="0.49.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="RichardSzalay.MockHttp" Version="7.0.0" />
<PackageReference Include="FluentAssertions" Version="[7.2.0]" />
</ItemGroup>
<PropertyGroup>
<CollectCoverage>true</CollectCoverage>
<CoverletOutput>./TestResults/Coverage/</CoverletOutput>
<CoverletOutputFormat>cobertura</CoverletOutputFormat>
<Include>[AltGen.Console]*</Include>
<ExcludeByFile>**/Program.cs</ExcludeByFile>
</PropertyGroup>
<Target Name="GenerateHtmlCoverageReport" AfterTargets="GenerateCoverageResultAfterTest">
<Exec
Command="reportgenerator -reports:./TestResults/Coverage/coverage.cobertura.xml -targetdir:./TestResults/Coverage/Html -reporttypes:Html_Dark" />
</Target>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AltGen.Console\AltGen.Console.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,118 @@
namespace AltGen.Console.Tests.Unit;
public class AddConfigCommandTests : IDisposable
{
readonly Mock<IAppSettingsManager> _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<AppSettings>(
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<AppSettings>(
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);
}
}
@@ -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<AltTextException>();
}
[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<AltTextException>();
}
public void Dispose()
{
_mockHttpMessageHandler.Dispose();
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,100 @@
namespace AltGen.Console.Tests.Unit;
public class AppSettingsManagerTests
{
readonly Mock<IFileSystem> _mockFileSystem = new();
readonly AppSettingsManager _sut;
public AppSettingsManagerTests()
{
_mockFileSystem
.Setup(static x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("path");
_sut = new AppSettingsManager(_mockFileSystem.Object);
}
[Fact]
public void AppSettingsExist_WhenSettingsDoNotExist_ItShouldReturnFalse()
{
_mockFileSystem
.Setup(static x => x.Path.Exists(It.IsAny<string>()))
.Returns(false);
var result = _sut.AppSettingsExist();
result.Should().BeFalse();
}
[Fact]
public void AppSettingsExist_WhenSettingsExist_ItShouldReturnTrue()
{
_mockFileSystem
.Setup(static x => x.Path.Exists(It.IsAny<string>()))
.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<string>(), 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<string>(), 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<string>(), default))
.ThrowsAsync(new FileNotFoundException());
var action = _sut.GetAppSettingsAsync;
await action.Should().ThrowAsync<FileNotFoundException>();
}
[Fact]
public async Task SaveAppSettingsAsync_WhenCalled_ItShouldSaveSettings()
{
_mockFileSystem
.Setup(static x => x.File.WriteAllTextAsync(It.IsAny<string>(), It.IsAny<string>(), 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<string>(), json, default), Times.Once);
}
}
@@ -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([]));
}
}
@@ -0,0 +1,324 @@
using System.Collections;
namespace AltGen.Console.Tests.Unit;
public class GenerateCommandTests : IDisposable
{
readonly TestConsole _testConsole = new();
readonly Mock<IFileSystem> _mockFileSystem = new();
readonly Mock<IAltGenService> _mockAltGenService = new();
readonly Mock<IAppSettingsManager> _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<string>()))
.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<string>()))
.Returns(true);
_mockFileSystem
.Setup(static x => x.Path.GetExtension(It.IsAny<string>()))
.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<string>()))
.Returns(true);
_mockFileSystem
.Setup(static x => x.Path.GetExtension(It.IsAny<string>()))
.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<AltTextException>();
}
[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<string>()))
.Returns("image.jpg");
_mockFileSystem
.Setup(static x => x.File.ReadAllBytesAsync(It.IsAny<string>(), default))
.ReturnsAsync([0x00]);
_mockFileSystem
.Setup(static x => x.Path.GetExtension(It.IsAny<string>()))
.Returns(".jpg");
_mockAltGenService
.Setup(static x => x.GenerateAltTextAsync(It.IsAny<GenerateAltTextRequest>()))
.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<GenerateAltTextRequest>(
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<string>()))
.Returns("image.jpg");
_mockFileSystem
.Setup(static x => x.File.ReadAllBytesAsync(It.IsAny<string>(), default))
.ReturnsAsync([0x00]);
_mockFileSystem
.Setup(static x => x.Path.GetExtension(It.IsAny<string>()))
.Returns(".jpg");
_mockAltGenService
.Setup(static x => x.GenerateAltTextAsync(It.IsAny<GenerateAltTextRequest>()))
.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<GenerateAltTextRequest>(
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<AltTextException>();
}
[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<AltTextException>();
}
[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<string>()))
.Returns("image.jpg");
_mockFileSystem
.Setup(static x => x.File.ReadAllBytesAsync(It.IsAny<string>(), default))
.ReturnsAsync([0x00]);
_mockFileSystem
.Setup(static x => x.Path.GetExtension(It.IsAny<string>()))
.Returns(".jpg");
_mockAltGenService
.Setup(static x => x.GenerateAltTextAsync(It.IsAny<GenerateAltTextRequest>()))
.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<GenerateAltTextRequest>(
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<string>()))
.Returns("image.jpg");
_mockFileSystem
.Setup(static x => x.File.ReadAllBytesAsync(It.IsAny<string>(), default))
.ReturnsAsync([0x00]);
_mockFileSystem
.Setup(static x => x.Path.GetExtension(It.IsAny<string>()))
.Returns(".jpg");
_mockAltGenService
.Setup(static x => x.GenerateAltTextAsync(It.IsAny<GenerateAltTextRequest>()))
.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<GenerateAltTextRequest>(
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);
}
}
@@ -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();
}
}
@@ -0,0 +1,86 @@
namespace AltGen.Console.Tests.Unit;
public class ListConfigCommandTests : IDisposable
{
readonly Mock<IAppSettingsManager> _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);
}
}
@@ -0,0 +1,91 @@
namespace AltGen.Console.Tests.Unit;
public class RemoveConfigCommandTests : IDisposable
{
readonly Mock<IAppSettingsManager> _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<ConfigException>();
}
[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);
}
}
@@ -0,0 +1,74 @@
namespace AltGen.Console.Tests.Unit;
public class TypeRegistrarTests
{
readonly Mock<IHostBuilder> _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<Action<HostBuilderContext, IServiceCollection>>()), 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<Action<HostBuilderContext, IServiceCollection>>()), 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<Action<HostBuilderContext, IServiceCollection>>()), Times.Once);
}
[Fact]
public void RegisterLazy_WhenCalledAndFuncIsNull_ItShouldThrowArgumentNullException()
{
var service = typeof(IService);
var act = () => _sut.RegisterLazy(service, null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void Build_WhenCalled_ItShouldReturnTypeResolver()
{
_mockBuilder.Setup(static x => x.Build()).Returns(Mock.Of<IHost>());
var actual = _sut.Build();
actual.Should().BeOfType<TypeResolver>();
_mockBuilder.Verify(static x => x.Build(), Times.Once);
}
interface IService { }
class Implementation : IService { }
}
@@ -0,0 +1,55 @@
namespace AltGen.Console.Tests.Unit;
public class TypeResolverTests
{
readonly Mock<IHost> _mockHost = new();
[Fact]
public void Constructor_WhenCalledWithNullHost_ItShouldThrowArgumentNullException()
{
var act = static () => new TypeResolver(null!);
act.Should().Throw<ArgumentNullException>();
}
[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 { }
}
+15
View File
@@ -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;
+50
View File
@@ -0,0 +1,50 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.1.0" />
<PackageReference Include="Spectre.Console" Version="0.49.1" />
<PackageReference Include="Spectre.Console.Cli" Version="0.49.1" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.3.1" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
</ItemGroup>
<PropertyGroup>
<GeneratedCodeDirectory>Generated</GeneratedCodeDirectory>
<GeneratedConstantsFile>$(GeneratedCodeDirectory)\Constants.cs</GeneratedConstantsFile>
<AltGenApiUri>https://localhost:7297</AltGenApiUri>
</PropertyGroup>
<Target Name="GenerateBuildConstants" BeforeTargets="BeforeBuild;BeforeRebuild">
<MakeDir Directories="$(GeneratedCodeDirectory)" />
<WriteLinesToFile
File="$(GeneratedConstantsFile)" Lines="
namespace $(AssemblyName).$(GeneratedCodeDirectory)%3b
static class Constants
{
public const string AltGenApiUri = &quot;$(AltGenApiUri)&quot;%3b
}
"
Overwrite="true" />
<ItemGroup>
<Compile Include="$(GeneratedConstantsFile)" Exclude="@(Compile)" />
</ItemGroup>
</Target>
</Project>
@@ -0,0 +1,29 @@
namespace AltGen.Console.Common;
static class HostBuilderExtensions
{
public static CommandApp<GenerateCommand> BuildApp(this IHostBuilder builder)
{
var registrar = new TypeRegistrar(builder);
var app = new CommandApp<GenerateCommand>(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<AddConfigCommand>("add");
c.AddCommand<RemoveConfigCommand>("remove");
c.AddCommand<ListConfigCommand>("list");
});
});
return app;
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace AltGen.Console.Common;
static class JsonOptions
{
public static JsonSerializerOptions Default { get; } = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
}
+17
View File
@@ -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
};
}
}
@@ -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<object> func)
{
ArgumentNullException.ThrowIfNull(func);
_builder.ConfigureServices((_, services) => services.AddSingleton(service, _ => func()));
}
}
+24
View File
@@ -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();
}
}
}
@@ -0,0 +1,54 @@
namespace AltGen.Console.Config;
sealed class AddConfigCommand(
IAnsiConsole console,
IAppSettingsManager settingsManager
) : AsyncCommand<AddConfigCommand.Settings>
{
readonly IAnsiConsole _console = console;
readonly IAppSettingsManager _settingsManager = settingsManager;
public class Settings : CommandSettings
{
[CommandArgument(1, "<provider>")]
[Description("The provider to configure.")]
public string Provider { get; init; } = string.Empty;
[CommandArgument(2, "<key>")]
[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<int> 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;
}
}
+62
View File
@@ -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);
}
}
@@ -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<AppSettings> GetAppSettingsAsync()
{
var existingJson = await _fileSystem.File.ReadAllTextAsync(GetSettingsPath());
var existingAppSettings = JsonSerializer.Deserialize<AppSettings>(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);
}
}
@@ -0,0 +1,5 @@
namespace AltGen.Console.Config;
class ConfigException(string message) : Exception(message)
{
}
@@ -0,0 +1,8 @@
namespace AltGen.Console.Config;
interface IAppSettingsManager
{
bool AppSettingsExist();
Task<AppSettings> GetAppSettingsAsync();
Task SaveAppSettingsAsync(AppSettings appSettings);
}
@@ -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<int> 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;
}
}
@@ -0,0 +1,3 @@
namespace AltGen.Console.Config;
record ProviderSettings(string Provider, string Key, bool Default);
@@ -0,0 +1,31 @@
namespace AltGen.Console.Config;
sealed class RemoveConfigCommand(
IAnsiConsole console,
IAppSettingsManager settingsManager
) : AsyncCommand<RemoveConfigCommand.Settings>
{
readonly IAnsiConsole _console = console;
readonly IAppSettingsManager _settingsManager = settingsManager;
public class Settings : CommandSettings
{
[CommandArgument(1, "<provider>")]
[Description("The provider to remove.")]
public string Provider { get; init; } = string.Empty;
}
public override async Task<int> 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;
}
}
@@ -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<string> 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<AltTextResponse>(content, JsonOptions) ?? throw new AltTextException("Failed to deserialize response.");
return altTextResponse.AltText;
}
}
record AltTextResponse(string AltText);
class AltTextException(string message) : Exception(message)
{
}
@@ -0,0 +1,9 @@
namespace AltGen.Console.Generate;
record GenerateAltTextRequest(
string Provider,
string ProviderKey,
string FileName,
byte[] Image,
string ContentType
);
@@ -0,0 +1,102 @@
namespace AltGen.Console.Generate;
sealed class GenerateCommand(
IAnsiConsole console,
IFileSystem fileSystem,
IAltGenService altGenService,
IAppSettingsManager settingsManager
) : AsyncCommand<GenerateCommand.Settings>
{
readonly IAnsiConsole _console = console;
readonly IFileSystem _fileSystem = fileSystem;
readonly IAltGenService _altGenService = altGenService;
readonly IAppSettingsManager _settingsManager = settingsManager;
public class Settings(IFileSystem fileSystem) : CommandSettings
{
readonly Dictionary<string, string> _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, "<path>")]
[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<int> 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;
}
}
@@ -0,0 +1,4 @@
interface IAltGenService
{
Task<string> GenerateAltTextAsync(GenerateAltTextRequest req);
}
@@ -0,0 +1,6 @@
namespace AltGen.Console.Generated;
static class Constants
{
public const string AltGenApiUri = "https://localhost:7297";
}
+12
View File
@@ -0,0 +1,12 @@
await Host.CreateDefaultBuilder(args)
.ConfigureLogging(static l => l.ClearProviders())
.ConfigureServices(static (_, services) =>
{
services.AddSingleton<IFileSystem, FileSystem>();
services.AddSingleton<IAppSettingsManager, AppSettingsManager>();
services.AddSingleton(AnsiConsole.Console);
services.AddHttpClient<IAltGenService, AltGenService>()
.AddStandardResilienceHandler();
})
.BuildApp()
.RunAsync(args);
+17
View File
@@ -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;
+12
View File
@@ -7,6 +7,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AltGen.API", "AltGen.API\Al
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AltGen.API.Tests", "AltGen.API.Tests\AltGen.API.Tests.csproj", "{FC95942B-3579-4F83-A447-A96D8EEF8E45}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AltGen.API.Tests", "AltGen.API.Tests\AltGen.API.Tests.csproj", "{FC95942B-3579-4F83-A447-A96D8EEF8E45}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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}.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.ActiveCfg = Release|Any CPU
{FC95942B-3579-4F83-A447-A96D8EEF8E45}.Release|Any CPU.Build.0 = 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 EndGlobalSection
EndGlobal EndGlobal