diff --git a/src/RolemasterDb.App/Components/Pages/Api.razor b/src/RolemasterDb.App/Components/Pages/Api.razor index ca1a313..dead3c6 100644 --- a/src/RolemasterDb.App/Components/Pages/Api.razor +++ b/src/RolemasterDb.App/Components/Pages/Api.razor @@ -61,6 +61,9 @@ "rollBand": "66-70", "groupKey": null, "columnKey": "C", + "isCurated": false, + "sourcePageNumber": 1, + "sourceImageUrl": "/api/tables/critical/slash/cells/412/source-image", "rawCellText": "Original imported full cell text", "descriptionText": "Current curated prose", "rawAffixText": "+8H - 2S", @@ -77,6 +80,12 @@

Use this to retrieve the full editable result graph for one critical-table cell, including nested branches, normalized effects, and review notes for unresolved quick-parse tokens.

+
+

Cell source image

+

GET /api/tables/critical/{slug}/cells/{resultId}/source-image

+

Streams the importer-generated PNG crop for the current critical cell. Returns 404 when the row has no stored crop or the artifact is missing.

+
+

Cell re-parse

POST /api/tables/critical/{slug}/cells/{resultId}/reparse

@@ -87,6 +96,7 @@ "rawAffixText": "+8H", "parseStatus": "partial", "parsedJson": "{}", + "isCurated": false, "isDescriptionOverridden": true, "isRawAffixTextOverridden": false, "areEffectsOverridden": false, @@ -103,10 +113,11 @@

PUT /api/tables/critical/{slug}/cells/{resultId}

{
   "rawCellText": "Corrected imported text",
-  "descriptionText": "Rewritten prose after manual review",
+    "descriptionText": "Rewritten prose after manual review",
   "rawAffixText": "+10H - must parry 2 rnds",
   "parseStatus": "manually_curated",
   "parsedJson": "{\"reviewed\":true}",
+  "isCurated": true,
   "isDescriptionOverridden": true,
   "isRawAffixTextOverridden": false,
   "areEffectsOverridden": false,
diff --git a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs
index ab02124..edc3b5e 100644
--- a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs
+++ b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs
@@ -15,6 +15,9 @@ public sealed class CriticalCellEditorModel
     public string ColumnKey { get; set; } = string.Empty;
     public string ColumnLabel { get; set; } = string.Empty;
     public string ColumnRole { get; set; } = string.Empty;
+    public bool IsCurated { get; set; }
+    public int? SourcePageNumber { get; set; }
+    public string? SourceImageUrl { get; set; }
     public string RawCellText { get; set; } = string.Empty;
     public string QuickParseInput { get; set; } = string.Empty;
     public string DescriptionText { get; set; } = string.Empty;
@@ -43,6 +46,9 @@ public sealed class CriticalCellEditorModel
             ColumnKey = response.ColumnKey,
             ColumnLabel = response.ColumnLabel,
             ColumnRole = response.ColumnRole,
+            IsCurated = response.IsCurated,
+            SourcePageNumber = response.SourcePageNumber,
+            SourceImageUrl = response.SourceImageUrl,
             RawCellText = response.RawCellText,
             QuickParseInput = response.QuickParseInput,
             DescriptionText = response.DescriptionText,
@@ -68,6 +74,7 @@ public sealed class CriticalCellEditorModel
             RawAffixText,
             ResolveParseStatus(Effects, Branches),
             SerializeParsedEffects(Effects),
+            IsCurated,
             IsDescriptionOverridden,
             IsRawAffixTextOverridden,
             AreEffectsOverridden,
@@ -101,6 +108,9 @@ public sealed class CriticalCellEditorModel
             ColumnKey = ColumnKey,
             ColumnLabel = ColumnLabel,
             ColumnRole = ColumnRole,
+            IsCurated = IsCurated,
+            SourcePageNumber = SourcePageNumber,
+            SourceImageUrl = SourceImageUrl,
             RawCellText = RawCellText,
             QuickParseInput = QuickParseInput,
             DescriptionText = DescriptionText,
diff --git a/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs b/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs
index 93de1a1..e35f4d9 100644
--- a/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs
+++ b/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs
@@ -13,6 +13,9 @@ public sealed record CriticalCellEditorResponse(
     string ColumnKey,
     string ColumnLabel,
     string ColumnRole,
+    bool IsCurated,
+    int? SourcePageNumber,
+    string? SourceImageUrl,
     string RawCellText,
     string QuickParseInput,
     string DescriptionText,
diff --git a/src/RolemasterDb.App/Features/CriticalCellUpdateRequest.cs b/src/RolemasterDb.App/Features/CriticalCellUpdateRequest.cs
index bedf0bf..9c6b90b 100644
--- a/src/RolemasterDb.App/Features/CriticalCellUpdateRequest.cs
+++ b/src/RolemasterDb.App/Features/CriticalCellUpdateRequest.cs
@@ -9,6 +9,7 @@ public sealed record CriticalCellUpdateRequest(
     string? RawAffixText,
     string ParseStatus,
     string ParsedJson,
+    bool IsCurated,
     bool IsDescriptionOverridden,
     bool IsRawAffixTextOverridden,
     bool AreEffectsOverridden,
diff --git a/src/RolemasterDb.App/Features/CriticalImportArtifactLocator.cs b/src/RolemasterDb.App/Features/CriticalImportArtifactLocator.cs
new file mode 100644
index 0000000..cc82230
--- /dev/null
+++ b/src/RolemasterDb.App/Features/CriticalImportArtifactLocator.cs
@@ -0,0 +1,44 @@
+namespace RolemasterDb.App.Features;
+
+public sealed class CriticalImportArtifactLocator
+{
+    public CriticalImportArtifactLocator(IHostEnvironment hostEnvironment)
+    {
+        ArtifactsRootPath = DiscoverArtifactsRootPath(hostEnvironment.ContentRootPath);
+    }
+
+    public string ArtifactsRootPath { get; }
+
+    public string? ResolveStoredPath(string? relativePath)
+    {
+        if (string.IsNullOrWhiteSpace(relativePath))
+        {
+            return null;
+        }
+
+        var candidate = Path.GetFullPath(Path.Combine(
+            ArtifactsRootPath,
+            relativePath.Replace('/', Path.DirectorySeparatorChar)));
+
+        return candidate.StartsWith(ArtifactsRootPath, StringComparison.OrdinalIgnoreCase)
+            ? candidate
+            : null;
+    }
+
+    private static string DiscoverArtifactsRootPath(string contentRootPath)
+    {
+        var probe = new DirectoryInfo(contentRootPath);
+
+        while (probe is not null)
+        {
+            if (File.Exists(Path.Combine(probe.FullName, "RolemasterDB.slnx")))
+            {
+                return Path.Combine(probe.FullName, "artifacts", "import", "critical");
+            }
+
+            probe = probe.Parent;
+        }
+
+        return Path.Combine(contentRootPath, "artifacts", "import", "critical");
+    }
+}
diff --git a/src/RolemasterDb.App/Features/LookupContracts.cs b/src/RolemasterDb.App/Features/LookupContracts.cs
index 4083375..2fd203e 100644
--- a/src/RolemasterDb.App/Features/LookupContracts.cs
+++ b/src/RolemasterDb.App/Features/LookupContracts.cs
@@ -110,6 +110,7 @@ public sealed record CriticalTableCellDetail(
     string ColumnRole,
     string? GroupKey,
     string? GroupLabel,
+    bool IsCurated,
     string? Description,
     IReadOnlyList Effects,
     IReadOnlyList Branches);
diff --git a/src/RolemasterDb.App/Features/LookupService.cs b/src/RolemasterDb.App/Features/LookupService.cs
index bc681e0..88d4524 100644
--- a/src/RolemasterDb.App/Features/LookupService.cs
+++ b/src/RolemasterDb.App/Features/LookupService.cs
@@ -9,7 +9,9 @@ using SharedParsing = RolemasterDb.CriticalParsing;
 
 namespace RolemasterDb.App.Features;
 
-public sealed class LookupService(IDbContextFactory dbContextFactory)
+public sealed class LookupService(
+    IDbContextFactory dbContextFactory,
+    CriticalImportArtifactLocator? artifactLocator = null)
 {
     public async Task GetReferenceDataAsync(CancellationToken cancellationToken = default)
     {
@@ -245,6 +247,7 @@ public sealed class LookupService(IDbContextFactory dbConte
                 result.CriticalColumn.Role,
                 result.CriticalGroup?.GroupKey,
                 result.CriticalGroup?.Label,
+                result.IsCurated,
                 result.DescriptionText,
                 result.Effects
                     .OrderBy(effect => effect.Id)
@@ -300,6 +303,28 @@ public sealed class LookupService(IDbContextFactory dbConte
         return CreateCellEditorResponse(result, currentState, generatedContent.ValidationErrors, CreateComparisonState(generatedContent));
     }
 
+    public async Task GetCriticalSourceImagePathAsync(string slug, int resultId, CancellationToken cancellationToken = default)
+    {
+        if (artifactLocator is null)
+        {
+            return null;
+        }
+
+        await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+
+        var normalizedSlug = NormalizeSlug(slug);
+        var relativePath = await dbContext.CriticalResults
+            .AsNoTracking()
+            .Where(item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug)
+            .Select(item => item.SourceImagePath)
+            .SingleOrDefaultAsync(cancellationToken);
+
+        var fullPath = artifactLocator.ResolveStoredPath(relativePath);
+        return fullPath is not null && File.Exists(fullPath)
+            ? fullPath
+            : null;
+    }
+
     public async Task ReparseCriticalCellAsync(
         string slug,
         int resultId,
@@ -363,6 +388,7 @@ public sealed class LookupService(IDbContextFactory dbConte
         result.RawAffixText = NormalizeOptionalText(request.RawAffixText);
         result.ParseStatus = request.ParseStatus.Trim();
         result.ParsedJson = CriticalCellEditorSnapshot.FromRequest(request).ToJson();
+        result.IsCurated = request.IsCurated;
 
         ReplaceBaseEffects(dbContext, result, request.Effects);
         ReplaceBranches(dbContext, result, request.Branches);
@@ -450,6 +476,9 @@ public sealed class LookupService(IDbContextFactory dbConte
             result.CriticalColumn.ColumnKey,
             result.CriticalColumn.Label,
             result.CriticalColumn.Role,
+            state.IsCurated,
+            result.SourcePageNumber,
+            CreateSourceImageUrl(result),
             state.RawCellText,
             state.QuickParseInput,
             state.DescriptionText,
@@ -596,6 +625,7 @@ public sealed class LookupService(IDbContextFactory dbConte
                 result.RawAffixText,
                 result.ParseStatus,
                 result.ParsedJson,
+                result.IsCurated,
                 snapshot.IsDescriptionOverridden,
                 snapshot.IsRawAffixTextOverridden,
                 snapshot.AreEffectsOverridden,
@@ -620,6 +650,7 @@ public sealed class LookupService(IDbContextFactory dbConte
             result.RawAffixText,
             result.ParseStatus,
             result.ParsedJson,
+            result.IsCurated,
             false,
             false,
             false,
@@ -645,6 +676,7 @@ public sealed class LookupService(IDbContextFactory dbConte
             RawAffixText: content.RawAffixText,
             ParseStatus: ResolveParseStatus(content.Effects, content.Branches),
             ParsedJson: SerializeParsedEffects(content.Effects),
+            IsCurated: false,
             IsDescriptionOverridden: false,
             IsRawAffixTextOverridden: false,
             AreEffectsOverridden: false,
@@ -663,6 +695,7 @@ public sealed class LookupService(IDbContextFactory dbConte
             currentState.IsRawAffixTextOverridden ? currentState.RawAffixText : generatedState.RawAffixText,
             generatedState.ParseStatus,
             generatedState.ParsedJson,
+            currentState.IsCurated,
             currentState.IsDescriptionOverridden,
             currentState.IsRawAffixTextOverridden,
             currentState.AreEffectsOverridden,
@@ -992,4 +1025,9 @@ public sealed class LookupService(IDbContextFactory dbConte
 
     private static string NormalizeSlug(string value) =>
         value.Trim().Replace(' ', '_').ToLowerInvariant();
+
+    private static string? CreateSourceImageUrl(CriticalResult result) =>
+        string.IsNullOrWhiteSpace(result.SourceImagePath)
+            ? null
+            : $"/api/tables/critical/{result.CriticalTable.Slug}/cells/{result.Id}/source-image";
 }
diff --git a/src/RolemasterDb.App/Program.cs b/src/RolemasterDb.App/Program.cs
index 2184265..cb7264c 100644
--- a/src/RolemasterDb.App/Program.cs
+++ b/src/RolemasterDb.App/Program.cs
@@ -9,6 +9,7 @@ var connectionString = builder.Configuration.GetConnectionString("RolemasterDb")
 builder.Services.AddRazorComponents()
     .AddInteractiveServerComponents();
 builder.Services.AddDbContextFactory(options => options.UseSqlite(connectionString));
+builder.Services.AddSingleton();
 builder.Services.AddScoped();
 
 var app = builder.Build();
@@ -40,6 +41,11 @@ api.MapGet("/tables/critical/{slug}/cells/{resultId:int}", async (string slug, i
     var result = await lookupService.GetCriticalCellEditorAsync(slug, resultId, cancellationToken);
     return result is null ? Results.NotFound() : Results.Ok(result);
 });
+api.MapGet("/tables/critical/{slug}/cells/{resultId:int}/source-image", async (string slug, int resultId, LookupService lookupService, CancellationToken cancellationToken) =>
+{
+    var filePath = await lookupService.GetCriticalSourceImagePathAsync(slug, resultId, cancellationToken);
+    return filePath is null ? Results.NotFound() : Results.File(filePath, "image/png");
+});
 api.MapPost("/tables/critical/{slug}/cells/{resultId:int}/reparse", async (string slug, int resultId, CriticalCellReparseRequest request, LookupService lookupService, CancellationToken cancellationToken) =>
 {
     var result = await lookupService.ReparseCriticalCellAsync(slug, resultId, request.CurrentState, cancellationToken);
diff --git a/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs b/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs
index b13f637..76c2994 100644
--- a/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs
+++ b/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs
@@ -432,6 +432,7 @@ public sealed class CriticalCellReparseIntegrationTests
                 initialResponse.RawAffixText,
                 initialResponse.ParseStatus,
                 initialResponse.ParsedJson,
+                initialResponse.IsCurated,
                 initialResponse.IsDescriptionOverridden,
                 initialResponse.IsRawAffixTextOverridden,
                 initialResponse.AreEffectsOverridden,
@@ -468,6 +469,7 @@ public sealed class CriticalCellReparseIntegrationTests
                 initialResponse.RawAffixText,
                 initialResponse.ParseStatus,
                 initialResponse.ParsedJson,
+                initialResponse.IsCurated,
                 initialResponse.IsDescriptionOverridden,
                 initialResponse.IsRawAffixTextOverridden,
                 initialResponse.AreEffectsOverridden,
@@ -493,6 +495,7 @@ public sealed class CriticalCellReparseIntegrationTests
                 reopenedResponse.RawAffixText,
                 reopenedResponse.ParseStatus,
                 reopenedResponse.ParsedJson,
+                reopenedResponse.IsCurated,
                 reopenedResponse.IsDescriptionOverridden,
                 reopenedResponse.IsRawAffixTextOverridden,
                 reopenedResponse.AreEffectsOverridden,
@@ -524,6 +527,7 @@ public sealed class CriticalCellReparseIntegrationTests
             rawAffixText,
             "partial",
             "{}",
+            false,
             isDescriptionOverridden,
             isRawAffixTextOverridden,
             areEffectsOverridden,
@@ -629,9 +633,16 @@ public sealed class CriticalCellReparseIntegrationTests
     {
         var databasePath = Path.Combine(Path.GetTempPath(), $"rolemaster-app-{Guid.NewGuid():N}.db");
         File.Copy(Path.Combine(GetRepositoryRoot(), "src", "RolemasterDb.App", "rolemaster.db"), databasePath, true);
+        UpgradeDatabase(databasePath).GetAwaiter().GetResult();
         return databasePath;
     }
 
+    private static async Task UpgradeDatabase(string databasePath)
+    {
+        await using var dbContext = CreateDbContext(databasePath);
+        await RolemasterDbSchemaUpgrader.EnsureLatestAsync(dbContext);
+    }
+
     private static string GetRepositoryRoot()
     {
         var probe = new DirectoryInfo(AppContext.BaseDirectory);
diff --git a/src/RolemasterDb.ImportTool.Tests/LookupServiceCurationIntegrationTests.cs b/src/RolemasterDb.ImportTool.Tests/LookupServiceCurationIntegrationTests.cs
new file mode 100644
index 0000000..c6080ca
--- /dev/null
+++ b/src/RolemasterDb.ImportTool.Tests/LookupServiceCurationIntegrationTests.cs
@@ -0,0 +1,184 @@
+using Microsoft.EntityFrameworkCore;
+
+using RolemasterDb.App.Data;
+using RolemasterDb.App.Domain;
+using RolemasterDb.App.Features;
+
+namespace RolemasterDb.ImportTool.Tests;
+
+public sealed class LookupServiceCurationIntegrationTests
+{
+    [Fact]
+    public async Task Lookup_service_surfaces_and_persists_curated_state_and_source_image_metadata()
+    {
+        var databasePath = CreateEmptyDatabasePath();
+        var repositoryRoot = CreateTemporaryRepositoryRoot();
+        var locator = new CriticalImportArtifactLocator(new TestHostEnvironment(Path.Combine(repositoryRoot, "src", "RolemasterDb.App")));
+
+        await SeedCriticalResultAsync(databasePath, "slash/cells/source-cell.png", 2);
+        WriteSourceImage(repositoryRoot, "slash/cells/source-cell.png");
+
+        var lookupService = new LookupService(CreateDbContextFactory(databasePath), locator);
+        var resultId = await GetResultIdAsync(databasePath);
+
+        var initialResponse = await lookupService.GetCriticalCellEditorAsync("slash", resultId);
+        Assert.NotNull(initialResponse);
+        Assert.False(initialResponse!.IsCurated);
+        Assert.Equal(2, initialResponse.SourcePageNumber);
+        Assert.Equal($"/api/tables/critical/slash/cells/{resultId}/source-image", initialResponse.SourceImageUrl);
+
+        var updateRequest = new CriticalCellUpdateRequest(
+            initialResponse.RawCellText,
+            initialResponse.QuickParseInput,
+            initialResponse.DescriptionText,
+            initialResponse.RawAffixText,
+            initialResponse.ParseStatus,
+            initialResponse.ParsedJson,
+            true,
+            initialResponse.IsDescriptionOverridden,
+            initialResponse.IsRawAffixTextOverridden,
+            initialResponse.AreEffectsOverridden,
+            initialResponse.AreBranchesOverridden,
+            initialResponse.Effects,
+            initialResponse.Branches);
+
+        var updatedResponse = await lookupService.UpdateCriticalCellAsync("slash", resultId, updateRequest);
+        Assert.NotNull(updatedResponse);
+        Assert.True(updatedResponse!.IsCurated);
+
+        var tableDetail = await lookupService.GetCriticalTableAsync("slash");
+        Assert.NotNull(tableDetail);
+        Assert.Contains(tableDetail!.Cells, item => item.ResultId == resultId && item.IsCurated);
+
+        var reopenedResponse = await lookupService.GetCriticalCellEditorAsync("slash", resultId);
+        Assert.NotNull(reopenedResponse);
+        Assert.True(reopenedResponse!.IsCurated);
+        Assert.Equal(initialResponse.SourceImageUrl, reopenedResponse.SourceImageUrl);
+    }
+
+    [Fact]
+    public async Task Lookup_service_resolves_source_image_paths_only_when_artifacts_exist()
+    {
+        var databasePath = CreateEmptyDatabasePath();
+        var repositoryRoot = CreateTemporaryRepositoryRoot();
+        var locator = new CriticalImportArtifactLocator(new TestHostEnvironment(Path.Combine(repositoryRoot, "src", "RolemasterDb.App")));
+
+        await SeedCriticalResultAsync(databasePath, "slash/cells/missing.png", 1);
+
+        var lookupService = new LookupService(CreateDbContextFactory(databasePath), locator);
+        var resultId = await GetResultIdAsync(databasePath);
+
+        var missingPath = await lookupService.GetCriticalSourceImagePathAsync("slash", resultId);
+        Assert.Null(missingPath);
+
+        WriteSourceImage(repositoryRoot, "slash/cells/missing.png");
+
+        var resolvedPath = await lookupService.GetCriticalSourceImagePathAsync("slash", resultId);
+        Assert.NotNull(resolvedPath);
+        Assert.True(File.Exists(resolvedPath));
+    }
+
+    private static async Task SeedCriticalResultAsync(string databasePath, string sourceImagePath, int sourcePageNumber)
+    {
+        await using var dbContext = CreateDbContext(databasePath);
+        await dbContext.Database.EnsureCreatedAsync();
+        await RolemasterDbSchemaUpgrader.EnsureLatestAsync(dbContext);
+
+        var table = new CriticalTable
+        {
+            Slug = "slash",
+            DisplayName = "Slash Critical Strike Table",
+            Family = "standard",
+            SourceDocument = "Slash.pdf"
+        };
+        var column = new CriticalColumn
+        {
+            CriticalTable = table,
+            ColumnKey = "B",
+            Label = "B",
+            Role = "severity",
+            SortOrder = 2
+        };
+        var rollBand = new CriticalRollBand
+        {
+            CriticalTable = table,
+            Label = "36-40",
+            MinRoll = 36,
+            MaxRoll = 40,
+            SortOrder = 8
+        };
+
+        var result = new CriticalResult
+        {
+            CriticalTable = table,
+            CriticalColumn = column,
+            CriticalRollBand = rollBand,
+            IsCurated = false,
+            RawCellText = "Imported raw cell text",
+            DescriptionText = "Imported description",
+            ParseStatus = "partial",
+            ParsedJson = "{}",
+            SourcePageNumber = sourcePageNumber,
+            SourceImagePath = sourceImagePath,
+            SourceImageCropJson = "{\"pageNumber\":1}"
+        };
+
+        result.Effects.Add(new CriticalEffect
+        {
+            EffectCode = CriticalEffectCodes.DirectHits,
+            ValueInteger = 5,
+            IsPermanent = false,
+            SourceType = "symbol",
+            SourceText = "+5H"
+        });
+
+        dbContext.CriticalTables.Add(table);
+        dbContext.CriticalResults.Add(result);
+        await dbContext.SaveChangesAsync();
+    }
+
+    private static void WriteSourceImage(string repositoryRoot, string relativePath)
+    {
+        var fullPath = Path.Combine(repositoryRoot, "artifacts", "import", "critical", relativePath.Replace('/', Path.DirectorySeparatorChar));
+        Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
+        File.WriteAllBytes(fullPath, [137, 80, 78, 71]);
+    }
+
+    private static async Task GetResultIdAsync(string databasePath)
+    {
+        await using var dbContext = CreateDbContext(databasePath);
+        return await dbContext.CriticalResults
+            .Where(item => item.CriticalTable.Slug == "slash")
+            .Select(item => item.Id)
+            .SingleAsync();
+    }
+
+    private static RolemasterDbContext CreateDbContext(string databasePath)
+    {
+        var options = new DbContextOptionsBuilder()
+            .UseSqlite($"Data Source={databasePath}")
+            .Options;
+
+        return new RolemasterDbContext(options);
+    }
+
+    private static IDbContextFactory CreateDbContextFactory(string databasePath)
+    {
+        var options = new DbContextOptionsBuilder()
+            .UseSqlite($"Data Source={databasePath}")
+            .Options;
+
+        return new TestRolemasterDbContextFactory(options);
+    }
+
+    private static string CreateEmptyDatabasePath() =>
+        Path.Combine(Path.GetTempPath(), $"lookup-service-curation-{Guid.NewGuid():N}.db");
+
+    private static string CreateTemporaryRepositoryRoot()
+    {
+        var repositoryRoot = Path.Combine(Path.GetTempPath(), $"rolemaster-repo-{Guid.NewGuid():N}");
+        Directory.CreateDirectory(Path.Combine(repositoryRoot, "src", "RolemasterDb.App"));
+        File.WriteAllText(Path.Combine(repositoryRoot, "RolemasterDB.slnx"), string.Empty);
+        return repositoryRoot;
+    }
+}
diff --git a/src/RolemasterDb.ImportTool.Tests/TestHostEnvironment.cs b/src/RolemasterDb.ImportTool.Tests/TestHostEnvironment.cs
new file mode 100644
index 0000000..127add9
--- /dev/null
+++ b/src/RolemasterDb.ImportTool.Tests/TestHostEnvironment.cs
@@ -0,0 +1,12 @@
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.Hosting;
+
+namespace RolemasterDb.ImportTool.Tests;
+
+internal sealed class TestHostEnvironment(string contentRootPath) : IHostEnvironment
+{
+    public string EnvironmentName { get; set; } = Environments.Development;
+    public string ApplicationName { get; set; } = "RolemasterDb.Tests";
+    public string ContentRootPath { get; set; } = contentRootPath;
+    public IFileProvider ContentRootFileProvider { get; set; } = new PhysicalFileProvider(contentRootPath);
+}