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