An unhandled error has occurred.
diff --git a/src/Wiki.Host/Components/Pages/Home.razor b/src/Wiki.Host/Components/Pages/Home.razor
index 9001e0b..eb503f3 100644
--- a/src/Wiki.Host/Components/Pages/Home.razor
+++ b/src/Wiki.Host/Components/Pages/Home.razor
@@ -1,7 +1,78 @@
-@page "/"
+@page "/"
+@rendermode InteractiveServer
+@using Wiki.Application.Status
+@inject StatusQuery Status
-
Home
+
Wiki status
-
Hello, world!
+
Status
-Welcome to your new app.
+@if (_status is null)
+{
+
+}
+else
+{
+
+
+
+ Index
+
+
+ | Ready | @(_status.Ready ? "yes" : "no") |
+ | Commit | @(_status.Commit is { Length: > 8 } c ? c[..8] : _status.Commit) |
+ | Synced | @(_status.SyncedAt?.ToString("u") ?? "never") (@(_status.AgeSeconds?.ToString("0") ?? "-") s ago) |
+ | Syncing | @(_status.Syncing ? "yes" : "no") |
+ | Notes | @_status.Notes |
+ | Raw queue | @_status.RawQueue |
+ | Outputs | @_status.Outputs |
+ @if (_status.LastError is not null)
+ {
+ | Last error | @_status.LastError |
+ }
+
+
+
+
+
+
+ Embedding models
+
+
+ @foreach (var i in _status.Indexes)
+ {
+ | @i.ModelId @(i.Active ? "(active)" : "") | @i.Notes vectors |
+ }
+
+
+
+
+
+
+ Adapters
+
+
+ @foreach (var a in _status.Adapters)
+ {
+
+ | @a.Adapter |
+ @(a.Ok ? "ok" : "failing") |
+ @a.Latency.TotalMilliseconds.ToString("0") ms |
+ @a.Detail |
+
+ }
+
+
+
+
+
+}
+
+@code {
+ private ServiceStatus? _status;
+
+ protected override async Task OnInitializedAsync()
+ {
+ _status = await Status.GetAsync(includeAdapterChecks: true, CancellationToken.None);
+ }
+}
diff --git a/src/Wiki.Host/Components/_Imports.razor b/src/Wiki.Host/Components/_Imports.razor
index 1b19ea5..5f50cd4 100644
--- a/src/Wiki.Host/Components/_Imports.razor
+++ b/src/Wiki.Host/Components/_Imports.razor
@@ -1,4 +1,4 @@
-@using System.Net.Http
+@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@@ -6,6 +6,7 @@
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
+@using MudBlazor
@using Wiki.Host
@using Wiki.Host.Components
@using Wiki.Host.Components.Layout
diff --git a/src/Wiki.Host/Composition/SyncBackgroundService.cs b/src/Wiki.Host/Composition/SyncBackgroundService.cs
new file mode 100644
index 0000000..0dc94c7
--- /dev/null
+++ b/src/Wiki.Host/Composition/SyncBackgroundService.cs
@@ -0,0 +1,32 @@
+using Wiki.Application.Sync;
+
+namespace Wiki.Host.Composition;
+
+///
Syncs at start, on every webhook trigger, and every interval; retries sooner after a failure.
+public sealed class SyncBackgroundService(SyncCoordinator coordinator, WikiOptions options, ILogger
log) : BackgroundService
+{
+ private readonly SemaphoreSlim _wake = new(0);
+
+ public void Trigger() => _wake.Release();
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ var summary = await coordinator.RunAsync(force: false, stoppingToken);
+ var wait = summary is null ? options.Sync.RetrySeconds : options.Sync.IntervalSeconds;
+ if (summary is null)
+ log.LogWarning("sync failed; retrying in {Seconds}s", wait);
+ try
+ {
+ await _wake.WaitAsync(TimeSpan.FromSeconds(wait), stoppingToken);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ // drain extra triggers that arrived during the run; one sync covers them
+ while (_wake.CurrentCount > 0) await _wake.WaitAsync(0, stoppingToken);
+ }
+ }
+}
diff --git a/src/Wiki.Host/Composition/WikiOptions.cs b/src/Wiki.Host/Composition/WikiOptions.cs
new file mode 100644
index 0000000..2b39673
--- /dev/null
+++ b/src/Wiki.Host/Composition/WikiOptions.cs
@@ -0,0 +1,55 @@
+namespace Wiki.Host.Composition;
+
+/// The `Wiki` configuration section: which adapters exist and which one is active. Secrets are read separately from configuration keys, never bound here.
+public sealed class WikiOptions
+{
+ public NoteStoreOptions NoteStore { get; set; } = new();
+ public EmbeddingsOptions Embeddings { get; set; } = new();
+ public IndexOptions Index { get; set; } = new();
+ public SyncScheduleOptions Sync { get; set; } = new();
+}
+
+public sealed class NoteStoreOptions
+{
+ public string Active { get; set; } = "gitea";
+ public GiteaStoreOptions Gitea { get; set; } = new();
+}
+
+public sealed class GiteaStoreOptions
+{
+ public string BaseUrl { get; set; } = "https://gitea.renner.uno";
+ public string Repository { get; set; } = "k.renner/notes";
+ public string Branch { get; set; } = "main";
+}
+
+public sealed class EmbeddingsOptions
+{
+ public string Active { get; set; } = "bge-m3";
+ public Dictionary Models { get; set; } = new();
+}
+
+public sealed class EmbeddingModelOptions
+{
+ public string Adapter { get; set; } = "LlamaCpp";
+ public string Url { get; set; } = string.Empty;
+ public string Model { get; set; } = string.Empty;
+ public int Dimension { get; set; } = 1024;
+}
+
+public sealed class IndexOptions
+{
+ public string Active { get; set; } = "file";
+ public FileIndexStoreOptions File { get; set; } = new();
+}
+
+public sealed class FileIndexStoreOptions
+{
+ public string Directory { get; set; } = "/data";
+}
+
+public sealed class SyncScheduleOptions
+{
+ /// Reconcile interval; the webhook is the fast path.
+ public int IntervalSeconds { get; set; } = 1800;
+ public int RetrySeconds { get; set; } = 60;
+}
diff --git a/src/Wiki.Host/Program.cs b/src/Wiki.Host/Program.cs
index f643367..17fc409 100644
--- a/src/Wiki.Host/Program.cs
+++ b/src/Wiki.Host/Program.cs
@@ -1,27 +1,105 @@
+using Microsoft.AspNetCore.Authentication;
+using MudBlazor.Services;
+using Wiki.Adapters.FileIndex;
+using Wiki.Adapters.Gitea;
+using Wiki.Adapters.LlamaCpp;
+using Wiki.Application.Retrieval;
+using Wiki.Application.Status;
+using Wiki.Application.Sync;
+using Wiki.Core.Ports;
+using Wiki.Host.Api;
+using Wiki.Host.Auth;
using Wiki.Host.Components;
+using Wiki.Host.Composition;
var builder = WebApplication.CreateBuilder(args);
+var wiki = builder.Configuration.GetSection("Wiki").Get() ?? new WikiOptions();
+builder.Services.AddSingleton(wiki);
+builder.Services.AddSingleton(TimeProvider.System);
-// Add services to the container.
-builder.Services.AddRazorComponents()
- .AddInteractiveServerComponents();
+// ---- adapters, selected by configuration ------------------------------------------------
+builder.Services.AddGiteaAdapters(o =>
+{
+ o.BaseUrl = wiki.NoteStore.Gitea.BaseUrl;
+ o.Repository = wiki.NoteStore.Gitea.Repository;
+ o.Branch = wiki.NoteStore.Gitea.Branch;
+ o.Token = builder.Configuration["Wiki:NoteStore:Gitea:Token"] ?? string.Empty;
+});
+builder.Services.AddFileIndexAdapters(o => o.Directory = wiki.Index.File.Directory);
+foreach (var (name, model) in wiki.Embeddings.Models)
+{
+ switch (model.Adapter)
+ {
+ case "LlamaCpp":
+ builder.Services.AddLlamaCppEmbeddings(name, o =>
+ {
+ o.Url = model.Url;
+ o.Model = model.Model;
+ o.ModelId = name;
+ o.Dimension = model.Dimension;
+ o.BasicAuth = builder.Configuration[$"Wiki:Embeddings:Models:{name}:BasicAuth"];
+ });
+ break;
+ default:
+ throw new InvalidOperationException($"unknown embeddings adapter '{model.Adapter}' for model '{name}'");
+ }
+}
+builder.Services.AddSingleton>(sp =>
+ wiki.Embeddings.Models.Keys.Select(sp.GetRequiredKeyedService).ToList());
+builder.Services.AddSingleton(sp => sp.GetRequiredKeyedService(wiki.Embeddings.Active));
+builder.Services.AddSingleton>(sp =>
+[
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ .. sp.GetRequiredService>(),
+]);
+
+// ---- use cases ---------------------------------------------------------------------------
+builder.Services.AddSingleton(new SyncOptions());
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddHostedService(sp => sp.GetRequiredService());
+
+// ---- auth: humans arrive through the Authentik outpost, machines with an API key ---------
+builder.Services.AddAuthentication(ProxyHeaderAuthenticationHandler.SchemeName)
+ .AddScheme(ProxyHeaderAuthenticationHandler.SchemeName, null)
+ .AddScheme(ApiKeyAuthenticationHandler.SchemeName, null);
+builder.Services.AddAuthorization(options =>
+{
+ options.AddPolicy(Policies.Write, p => p.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName).RequireAuthenticatedUser());
+});
+
+// ---- UI ----------------------------------------------------------------------------------
+builder.Services.AddRazorComponents().AddInteractiveServerComponents();
+builder.Services.AddMudServices();
var app = builder.Build();
-// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
- // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
- app.UseHsts();
}
-app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
-app.UseHttpsRedirection();
-
+// The not-found page is for the UI only: an API 401/404 must stay a JSON status, not be
+// re-executed as a POST against a Blazor page (which fails antiforgery with an empty 400).
+app.UseWhen(
+ ctx => HttpMethods.IsGet(ctx.Request.Method) && !ctx.Request.Path.StartsWithSegments("/api"),
+ branch => branch.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true));
+app.UseAuthentication();
+app.UseAuthorization();
app.UseAntiforgery();
+app.MapHealthEndpoints();
+app.MapIndexEndpoints();
+app.MapApiV1();
+
app.MapStaticAssets();
-app.MapRazorComponents()
- .AddInteractiveServerRenderMode();
+app.MapRazorComponents().AddInteractiveServerRenderMode();
app.Run();
+
+public partial class Program;
diff --git a/src/Wiki.Host/Wiki.Host.csproj b/src/Wiki.Host/Wiki.Host.csproj
index 90d0b37..2a9ccc1 100644
--- a/src/Wiki.Host/Wiki.Host.csproj
+++ b/src/Wiki.Host/Wiki.Host.csproj
@@ -9,6 +9,10 @@
+
+
+
+
net10.0
enable
diff --git a/src/Wiki.Host/appsettings.json b/src/Wiki.Host/appsettings.json
index 10f68b8..0a93de3 100644
--- a/src/Wiki.Host/appsettings.json
+++ b/src/Wiki.Host/appsettings.json
@@ -2,8 +2,27 @@
"Logging": {
"LogLevel": {
"Default": "Information",
- "Microsoft.AspNetCore": "Warning"
+ "Microsoft.AspNetCore": "Warning",
+ "System.Net.Http.HttpClient": "Warning"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "Wiki": {
+ "NoteStore": {
+ "Active": "gitea",
+ "Gitea": { "BaseUrl": "https://gitea.renner.uno", "Repository": "k.renner/notes", "Branch": "main" }
+ },
+ "Embeddings": {
+ "Active": "bge-m3",
+ "Models": {
+ "bge-m3": { "Adapter": "LlamaCpp", "Url": "http://wiki-embed.ai.svc.cluster.local:8080", "Model": "bge-m3", "Dimension": 1024 }
+ }
+ },
+ "Index": { "Active": "file", "File": { "Directory": "/data" } },
+ "Sync": { "IntervalSeconds": 1800, "RetrySeconds": 60 }
+ },
+ "Auth": {
+ "ReverseProxy": { "HeaderName": "X-Authentik-Username", "TrustedProxies": [] },
+ "ApiKeys": {}
+ }
}
diff --git a/test/Wiki.Application.Tests/Fakes.cs b/test/Wiki.Application.Tests/Fakes.cs
new file mode 100644
index 0000000..2d591a5
--- /dev/null
+++ b/test/Wiki.Application.Tests/Fakes.cs
@@ -0,0 +1,100 @@
+using System.Security.Cryptography;
+using System.Text;
+using Wiki.Core.Model;
+using Wiki.Core.Parsing;
+using Wiki.Core.Ports;
+
+namespace Wiki.Application.Tests;
+
+/// In-memory ports for use-case tests. Content hash = SHA-1 of the text, like a git blob.
+public sealed class FakeStore : INoteStore
+{
+ public Dictionary Files { get; } = new();
+ public string Head { get; set; } = "c1";
+ public int Reads { get; private set; }
+ public string Name => "fake-store";
+
+ public Task GetHeadAsync(CancellationToken ct) => Task.FromResult(new StoreHead(Head, null));
+
+ public Task> ListAsync(string commit, IReadOnlySet kinds, CancellationToken ct)
+ {
+ var refs = new List();
+ foreach (var (path, text) in Files)
+ {
+ var c = DocumentParser.Classify(path);
+ if (c is null || !kinds.Contains(c.Value.Kind)) continue;
+ refs.Add(new DocumentRef(c.Value.Kind, c.Value.Slug, path, Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(text)))));
+ }
+ return Task.FromResult>(refs);
+ }
+
+ public Task ReadAsync(DocumentRef reference, string commit, CancellationToken ct)
+ {
+ Reads++;
+ return Task.FromResult(Files[reference.Path]);
+ }
+
+ public Task CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
+}
+
+public sealed class FakeCatalog : IDocumentCatalog
+{
+ private readonly Dictionary<(DocumentKind, string), Document> _docs = new();
+ private CatalogState _state = new(null, null);
+ public int Flushes { get; private set; }
+ public string Name => "fake-catalog";
+
+ public Task GetStateAsync(CancellationToken ct) => Task.FromResult(_state);
+ public Task SetStateAsync(CatalogState state, CancellationToken ct) { _state = state; return Task.CompletedTask; }
+ public Task UpsertAsync(IReadOnlyList documents, CancellationToken ct) { foreach (var d in documents) _docs[(d.Kind, d.Slug)] = d; return Task.CompletedTask; }
+ public Task RemoveAsync(IReadOnlyList references, CancellationToken ct) { foreach (var r in references) _docs.Remove((r.Kind, r.Slug)); return Task.CompletedTask; }
+ public Task GetAsync(DocumentKind kind, string slug, CancellationToken ct) => Task.FromResult(_docs.GetValueOrDefault((kind, slug)));
+ public Task> ListAsync(DocumentKind kind, CancellationToken ct) => Task.FromResult>(_docs.Values.Where(d => d.Kind == kind).Select(d => d.Ref).ToList());
+ public Task> ListDocumentsAsync(DocumentKind kind, CancellationToken ct) => Task.FromResult>(_docs.Values.Where(d => d.Kind == kind).ToList());
+ public Task> BacklinksAsync(string slug, CancellationToken ct) => Task.FromResult>(_docs.Values.Where(d => d.Kind == DocumentKind.Wiki).SelectMany(WikiLinks.Extract).Where(l => l.ToSlug == slug).ToList());
+ public Task> OutgoingLinksAsync(string slug, CancellationToken ct) => Task.FromResult>(_docs.TryGetValue((DocumentKind.Wiki, slug), out var d) ? WikiLinks.Extract(d) : []);
+ public Task> TagCountsAsync(CancellationToken ct) => Task.FromResult>(_docs.Values.Where(d => d.Kind == DocumentKind.Wiki).SelectMany(d => d.Tags).GroupBy(t => t).ToDictionary(g => g.Key, g => g.Count()));
+ public Task FlushAsync(CancellationToken ct) { Flushes++; return Task.CompletedTask; }
+ public Task CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
+}
+
+public sealed class FakeIndex : IVectorIndex
+{
+ public Dictionary> Models { get; } = new();
+ public int Flushes { get; private set; }
+ public string Name => "fake-index";
+
+ private Dictionary Of(string model) => Models.TryGetValue(model, out var m) ? m : Models[model] = new();
+
+ public Task DescribeAsync(string modelId, CancellationToken ct) => Task.FromResult(new IndexState(modelId, Of(modelId).ToDictionary(kv => kv.Key, kv => kv.Value.Hash)));
+ public Task UpsertAsync(IReadOnlyList embeddings, CancellationToken ct) { foreach (var e in embeddings) Of(e.ModelId)[e.Slug] = (e.ContentHash, e.Vector); return Task.CompletedTask; }
+ public Task RemoveAsync(string modelId, IReadOnlyList slugs, CancellationToken ct) { foreach (var s in slugs) Of(modelId).Remove(s); return Task.CompletedTask; }
+ public Task> QueryAsync(string modelId, float[] query, int k, CancellationToken ct) =>
+ Task.FromResult>(Of(modelId).Select(kv => new RankedHit(kv.Key, kv.Value.Vector.Zip(query, (a, b) => a * b).Sum())).OrderByDescending(h => h.Score).Take(k).ToList());
+ public Task FlushAsync(CancellationToken ct) { Flushes++; return Task.CompletedTask; }
+ public Task CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
+}
+
+/// Deterministic 4-dimensional "embeddings": a hash of the text spread over the axes, unit length.
+public sealed class FakeEmbeddings(string modelId) : IEmbeddingModel
+{
+ public string ModelId { get; } = modelId;
+ public int Dimension => 4;
+ public List> Batches { get; } = [];
+ public string Name => "fake-embeddings:" + ModelId;
+
+ public Task> EmbedAsync(IReadOnlyList texts, CancellationToken ct)
+ {
+ Batches.Add(texts);
+ var vectors = texts.Select(t =>
+ {
+ var h = SHA256.HashData(Encoding.UTF8.GetBytes(ModelId + t));
+ var v = new float[] { h[0] + 1, h[1] + 1, h[2] + 1, h[3] + 1 };
+ var n = MathF.Sqrt(v.Sum(x => x * x));
+ return v.Select(x => x / n).ToArray();
+ }).ToList();
+ return Task.FromResult>(vectors);
+ }
+
+ public Task CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
+}
diff --git a/test/Wiki.Application.Tests/SyncUseCaseTests.cs b/test/Wiki.Application.Tests/SyncUseCaseTests.cs
new file mode 100644
index 0000000..63461e9
--- /dev/null
+++ b/test/Wiki.Application.Tests/SyncUseCaseTests.cs
@@ -0,0 +1,143 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Wiki.Application.Retrieval;
+using Wiki.Application.Sync;
+using Wiki.Core.Model;
+using Wiki.Core.Ports;
+
+namespace Wiki.Application.Tests;
+
+public class SyncUseCaseTests
+{
+ private static string Note(string title, string body = "Body.", string tags = "[a]") =>
+ $"---\ntitle: {title}\ntype: wiki\ntags: {tags}\nsources: [x]\nupdated: 2026-09-01\n---\n\n# {title}\n\n{body}\n";
+
+ private readonly FakeStore _store = new();
+ private readonly FakeCatalog _catalog = new();
+ private readonly FakeIndex _index = new();
+ private readonly FakeEmbeddings _model = new("m1");
+
+ private SyncUseCase Sync(params IEmbeddingModel[] models) =>
+ new(_store, _catalog, _index, models.Length == 0 ? [_model] : models, TimeProvider.System, NullLogger.Instance, new SyncOptions(EmbedChunk: 2));
+
+ [Fact]
+ public async Task InitialSyncEmbedsWikiOnlyAndCatalogsEverything()
+ {
+ _store.Files["wiki/a.md"] = Note("A");
+ _store.Files["wiki/b.md"] = Note("B");
+ _store.Files["wiki/c.md"] = Note("C");
+ _store.Files["raw/2026-09-23-r.md"] = "---\ntitle: R\n---\n\nraw\n";
+ _store.Files["output/2026-09-23-o.md"] = "---\ntitle: O\n---\n\nout\n";
+ _store.Files["wiki/_index.md"] = "index";
+
+ var summary = await Sync().RunAsync(false, CancellationToken.None);
+
+ Assert.False(summary.Skipped);
+ Assert.Equal(5, summary.Changed);
+ Assert.Equal(3, summary.Embedded);
+ Assert.Equal(3, summary.Notes);
+ Assert.Equal(3, (await _index.DescribeAsync("m1", CancellationToken.None)).Count);
+ Assert.Single(await _catalog.ListAsync(DocumentKind.Raw, CancellationToken.None));
+ Assert.Single(await _catalog.ListAsync(DocumentKind.Output, CancellationToken.None));
+ Assert.Equal("c1", (await _catalog.GetStateAsync(CancellationToken.None)).Commit);
+ Assert.All(_model.Batches, b => Assert.InRange(b.Count, 1, 2));
+ Assert.Equal(1, _index.Flushes);
+ Assert.Equal(1, _catalog.Flushes);
+ }
+
+ [Fact]
+ public async Task UnchangedHeadIsSkippedWithoutReads()
+ {
+ _store.Files["wiki/a.md"] = Note("A");
+ await Sync().RunAsync(false, CancellationToken.None);
+ var reads = _store.Reads;
+
+ var summary = await Sync().RunAsync(false, CancellationToken.None);
+
+ Assert.True(summary.Skipped);
+ Assert.Equal(reads, _store.Reads);
+ Assert.NotNull((await _catalog.GetStateAsync(CancellationToken.None)).SyncedAt);
+ }
+
+ [Fact]
+ public async Task ChangedDocumentIsReembeddedAndRemovedOneDropped()
+ {
+ _store.Files["wiki/a.md"] = Note("A");
+ _store.Files["wiki/b.md"] = Note("B");
+ await Sync().RunAsync(false, CancellationToken.None);
+ var before = (await _index.DescribeAsync("m1", CancellationToken.None)).ContentHashBySlug;
+
+ _store.Head = "c2";
+ _store.Files["wiki/a.md"] = Note("A", "Changed body.");
+ _store.Files.Remove("wiki/b.md");
+ var reads = _store.Reads;
+
+ var summary = await Sync().RunAsync(false, CancellationToken.None);
+
+ Assert.Equal(1, summary.Changed);
+ Assert.Equal(1, summary.Removed);
+ Assert.Equal(1, _store.Reads - reads);
+ var after = (await _index.DescribeAsync("m1", CancellationToken.None)).ContentHashBySlug;
+ Assert.Equal(["a"], after.Keys);
+ Assert.NotEqual(before["a"], after["a"]);
+ Assert.Null(await _catalog.GetAsync(DocumentKind.Wiki, "b", CancellationToken.None));
+ Assert.Contains("Changed body", (await _catalog.GetAsync(DocumentKind.Wiki, "a", CancellationToken.None))!.Body);
+ }
+
+ [Fact]
+ public async Task ForceResyncsAnUnchangedHead()
+ {
+ _store.Files["wiki/a.md"] = Note("A");
+ await Sync().RunAsync(false, CancellationToken.None);
+ var summary = await Sync().RunAsync(true, CancellationToken.None);
+ Assert.False(summary.Skipped);
+ Assert.Equal(0, summary.Changed);
+ }
+
+ [Fact]
+ public async Task ALaterRegisteredModelIsBackfilledFromTheCatalog()
+ {
+ _store.Files["wiki/a.md"] = Note("A");
+ _store.Files["wiki/b.md"] = Note("B");
+ await Sync().RunAsync(false, CancellationToken.None);
+
+ var second = new FakeEmbeddings("m2");
+ var summary = await Sync(_model, second).RunAsync(true, CancellationToken.None);
+
+ Assert.Equal(2, summary.Embedded);
+ Assert.Equal(2, (await _index.DescribeAsync("m2", CancellationToken.None)).Count);
+ Assert.Equal(2, (await _index.DescribeAsync("m1", CancellationToken.None)).Count);
+ }
+
+ [Fact]
+ public async Task RankReturnsTitlesFromTheCatalog()
+ {
+ _store.Files["wiki/udm-dns.md"] = Note("UDM intercepts DNS", "The UDM answers DNS.");
+ _store.Files["wiki/ceph-osd.md"] = Note("Ceph OSD", "OSD fsid.");
+ await Sync().RunAsync(false, CancellationToken.None);
+
+ var rank = new RankUseCase(_index, _catalog);
+ var udm = (await _catalog.GetAsync(DocumentKind.Wiki, "udm-dns", CancellationToken.None))!;
+ var rows = await rank.RankAsync(_model, Wiki.Core.Parsing.EmbeddingText.For(udm), 5, CancellationToken.None);
+
+ Assert.Equal(2, rows.Count);
+ Assert.Equal("udm-dns", rows[0].Slug);
+ Assert.Equal("UDM intercepts DNS", rows[0].Title);
+ Assert.InRange(rows[0].Score, 0.99f, 1.01f);
+ await Assert.ThrowsAsync(() => rank.RankAsync(_model, " ", 5, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task CoordinatorRecordsFailureAndRecovers()
+ {
+ _store.Files["wiki/a.md"] = Note("A");
+ var coordinator = new SyncCoordinator(Sync(), TimeProvider.System, NullLogger.Instance);
+ _store.Head = null!; // GetHeadAsync returns a null commit → the use case fails on Substring
+ Assert.Null(await coordinator.RunAsync(false, CancellationToken.None));
+ Assert.NotNull(coordinator.LastError);
+ Assert.False(coordinator.Syncing);
+
+ _store.Head = "c1";
+ Assert.NotNull(await coordinator.RunAsync(false, CancellationToken.None));
+ Assert.Null(coordinator.LastError);
+ }
+}
diff --git a/test/Wiki.Application.Tests/Wiki.Application.Tests.csproj b/test/Wiki.Application.Tests/Wiki.Application.Tests.csproj
index 61e9574..9d03af8 100644
--- a/test/Wiki.Application.Tests/Wiki.Application.Tests.csproj
+++ b/test/Wiki.Application.Tests/Wiki.Application.Tests.csproj
@@ -9,6 +9,7 @@
+
diff --git a/test/Wiki.Contracts/DocumentCatalogContract.cs b/test/Wiki.Contracts/DocumentCatalogContract.cs
new file mode 100644
index 0000000..d81bd30
--- /dev/null
+++ b/test/Wiki.Contracts/DocumentCatalogContract.cs
@@ -0,0 +1,112 @@
+using Wiki.Core.Model;
+using Wiki.Core.Parsing;
+using Wiki.Core.Ports;
+
+namespace Wiki.Contracts;
+
+/// Every adapter derives from this.
+public abstract class DocumentCatalogContract
+{
+ protected abstract Task CreateCatalogAsync();
+ protected abstract Task ReopenAsync(IDocumentCatalog catalog);
+
+ private static Document Doc(string path, string hash = "h") =>
+ DocumentParser.Parse(Ref(path, hash), VaultFixture.Files[path]);
+
+ private static DocumentRef Ref(string path, string hash = "h")
+ {
+ var (kind, slug) = DocumentParser.Classify(path)!.Value;
+ return new DocumentRef(kind, slug, path, hash);
+ }
+
+ [Fact]
+ public async Task StartsEmptyWithNoState()
+ {
+ var catalog = await CreateCatalogAsync();
+ var state = await catalog.GetStateAsync(CancellationToken.None);
+ Assert.Null(state.Commit);
+ Assert.Empty(await catalog.ListAsync(DocumentKind.Wiki, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task UpsertGetListByKind()
+ {
+ var catalog = await CreateCatalogAsync();
+ await catalog.UpsertAsync([Doc("wiki/udm-dns.md"), Doc("wiki/ceph-osd.md"), Doc("raw/2026-09-23-pending.md"), Doc("output/2026-09-20-answer.md")], CancellationToken.None);
+
+ var got = await catalog.GetAsync(DocumentKind.Wiki, "udm-dns", CancellationToken.None);
+ Assert.NotNull(got);
+ Assert.Equal("UDM intercepts DNS", got.Title);
+ Assert.Contains("answers every UDP/53", got.Body);
+ Assert.Null(await catalog.GetAsync(DocumentKind.Raw, "udm-dns", CancellationToken.None));
+
+ Assert.Equal(2, (await catalog.ListAsync(DocumentKind.Wiki, CancellationToken.None)).Count);
+ Assert.Single(await catalog.ListAsync(DocumentKind.Raw, CancellationToken.None));
+ Assert.Single(await catalog.ListDocumentsAsync(DocumentKind.Output, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task LinksAndBacklinksFollowWikilinksAndRelated()
+ {
+ var catalog = await CreateCatalogAsync();
+ await catalog.UpsertAsync([Doc("wiki/udm-dns.md"), Doc("wiki/ceph-osd.md")], CancellationToken.None);
+ var outgoing = await catalog.OutgoingLinksAsync("udm-dns", CancellationToken.None);
+ Assert.Equal([LinkKind.Related, LinkKind.WikiLink], outgoing.Select(l => l.Kind));
+ Assert.All(outgoing, l => Assert.Equal("ceph-osd", l.ToSlug));
+ var back = await catalog.BacklinksAsync("ceph-osd", CancellationToken.None);
+ Assert.Equal(2, back.Count);
+ Assert.All(back, l => Assert.Equal("udm-dns", l.FromSlug));
+ Assert.Empty(await catalog.BacklinksAsync("udm-dns", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task RemoveDropsDocumentAndItsLinks()
+ {
+ var catalog = await CreateCatalogAsync();
+ await catalog.UpsertAsync([Doc("wiki/udm-dns.md"), Doc("wiki/ceph-osd.md")], CancellationToken.None);
+ await catalog.RemoveAsync([Ref("wiki/udm-dns.md")], CancellationToken.None);
+ Assert.Null(await catalog.GetAsync(DocumentKind.Wiki, "udm-dns", CancellationToken.None));
+ Assert.Empty(await catalog.BacklinksAsync("ceph-osd", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task UpsertReplacesLinks()
+ {
+ var catalog = await CreateCatalogAsync();
+ await catalog.UpsertAsync([Doc("wiki/udm-dns.md")], CancellationToken.None);
+ var changed = Doc("wiki/udm-dns.md", "h2") with { Body = "no links any more", Related = [] };
+ await catalog.UpsertAsync([changed], CancellationToken.None);
+ Assert.Empty(await catalog.OutgoingLinksAsync("udm-dns", CancellationToken.None));
+ Assert.Equal("h2", (await catalog.GetAsync(DocumentKind.Wiki, "udm-dns", CancellationToken.None))!.ContentHash);
+ }
+
+ [Fact]
+ public async Task TagCountsCoverWikiOnly()
+ {
+ var catalog = await CreateCatalogAsync();
+ await catalog.UpsertAsync([Doc("wiki/udm-dns.md"), Doc("wiki/ceph-osd.md"), Doc("output/2026-09-20-answer.md")], CancellationToken.None);
+ var tags = await catalog.TagCountsAsync(CancellationToken.None);
+ Assert.Equal(1, tags["dns"]);
+ Assert.Equal(1, tags["udm"]);
+ Assert.Equal(1, tags["ceph"]);
+ Assert.Equal(3, tags.Count);
+ }
+
+ [Fact]
+ public async Task StateAndDocumentsSurviveFlushAndReopen()
+ {
+ var catalog = await CreateCatalogAsync();
+ await catalog.UpsertAsync([Doc("wiki/udm-dns.md"), Doc("wiki/ceph-osd.md")], CancellationToken.None);
+ var at = new DateTimeOffset(2026, 9, 24, 10, 0, 0, TimeSpan.Zero);
+ await catalog.SetStateAsync(new CatalogState(VaultFixture.Commit, at), CancellationToken.None);
+ await catalog.FlushAsync(CancellationToken.None);
+
+ var reopened = await ReopenAsync(catalog);
+ var state = await reopened.GetStateAsync(CancellationToken.None);
+ Assert.Equal(VaultFixture.Commit, state.Commit);
+ Assert.Equal(at, state.SyncedAt);
+ Assert.Equal(2, (await reopened.ListAsync(DocumentKind.Wiki, CancellationToken.None)).Count);
+ Assert.Equal(2, (await reopened.BacklinksAsync("ceph-osd", CancellationToken.None)).Count);
+ Assert.Equal("UDM intercepts DNS", (await reopened.GetAsync(DocumentKind.Wiki, "udm-dns", CancellationToken.None))!.Title);
+ }
+}
diff --git a/test/Wiki.Contracts/EmbeddingModelContract.cs b/test/Wiki.Contracts/EmbeddingModelContract.cs
new file mode 100644
index 0000000..ff47987
--- /dev/null
+++ b/test/Wiki.Contracts/EmbeddingModelContract.cs
@@ -0,0 +1,49 @@
+using Wiki.Core.Ports;
+
+namespace Wiki.Contracts;
+
+/// Every adapter derives from this. The adapter under test may talk to a fake server.
+public abstract class EmbeddingModelContract
+{
+ protected abstract Task CreateModelAsync();
+
+ [Fact]
+ public async Task VectorsHaveTheDeclaredDimensionAndUnitLength()
+ {
+ var model = await CreateModelAsync();
+ var vectors = await model.EmbedAsync(["alpha", "beta"], CancellationToken.None);
+ Assert.Equal(2, vectors.Count);
+ foreach (var v in vectors)
+ {
+ Assert.Equal(model.Dimension, v.Length);
+ var norm = MathF.Sqrt(v.Sum(x => x * x));
+ Assert.InRange(norm, 0.99f, 1.01f);
+ }
+ }
+
+ [Fact]
+ public async Task SameTextSameVectorAndOrderPreserved()
+ {
+ var model = await CreateModelAsync();
+ var once = await model.EmbedAsync(["one", "two", "three"], CancellationToken.None);
+ var again = await model.EmbedAsync(["three", "one"], CancellationToken.None);
+ Assert.Equal(once[2], again[0]);
+ Assert.Equal(once[0], again[1]);
+ }
+
+ [Fact]
+ public async Task EmptyBatchIsEmpty()
+ {
+ var model = await CreateModelAsync();
+ Assert.Empty(await model.EmbedAsync([], CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ModelIdIsSet()
+ {
+ var model = await CreateModelAsync();
+ Assert.False(string.IsNullOrWhiteSpace(model.ModelId));
+ var health = await model.CheckAsync(CancellationToken.None);
+ Assert.True(health.Ok, health.Detail);
+ }
+}
diff --git a/test/Wiki.Contracts/FileIndex/FileDocumentCatalogContractTests.cs b/test/Wiki.Contracts/FileIndex/FileDocumentCatalogContractTests.cs
new file mode 100644
index 0000000..6367a25
--- /dev/null
+++ b/test/Wiki.Contracts/FileIndex/FileDocumentCatalogContractTests.cs
@@ -0,0 +1,45 @@
+using Microsoft.Extensions.Options;
+using Wiki.Adapters.FileIndex;
+using Wiki.Core.Ports;
+
+namespace Wiki.Contracts.FileIndex;
+
+public sealed class FileDocumentCatalogContractTests : DocumentCatalogContract, IDisposable
+{
+ private readonly List _directories = [];
+ private readonly Dictionary _directoryOf = new(ReferenceEqualityComparer.Instance);
+
+ protected override Task CreateCatalogAsync()
+ {
+ var directory = NewDirectory();
+ IDocumentCatalog catalog = new Wiki.Adapters.FileIndex.FileDocumentCatalog(Options.Create(new FileIndexOptions { Directory = directory }));
+ _directoryOf[catalog] = directory;
+ return Task.FromResult(catalog);
+ }
+
+ protected override Task ReopenAsync(IDocumentCatalog catalog)
+ {
+ var directory = _directoryOf[catalog];
+ IDocumentCatalog reopened = new Wiki.Adapters.FileIndex.FileDocumentCatalog(Options.Create(new FileIndexOptions { Directory = directory }));
+ _directoryOf[reopened] = directory;
+ return Task.FromResult(reopened);
+ }
+
+ private string NewDirectory()
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "wiki-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+ _directories.Add(directory);
+ return directory;
+ }
+
+ public void Dispose()
+ {
+ foreach (var directory in _directories)
+ {
+ try { Directory.Delete(directory, recursive: true); }
+ catch (IOException) { }
+ catch (UnauthorizedAccessException) { }
+ }
+ }
+}
diff --git a/test/Wiki.Contracts/FileIndex/FileIndexUnitTests.cs b/test/Wiki.Contracts/FileIndex/FileIndexUnitTests.cs
new file mode 100644
index 0000000..59902a3
--- /dev/null
+++ b/test/Wiki.Contracts/FileIndex/FileIndexUnitTests.cs
@@ -0,0 +1,98 @@
+using Microsoft.Extensions.Options;
+using Wiki.Adapters.FileIndex;
+using Wiki.Core.Model;
+using Wiki.Core.Ports;
+
+namespace Wiki.Contracts.FileIndex;
+
+/// Behaviour specific to the file-backed adapters that the shared ports contracts don't exercise: atomicity and resilience to corrupt state on disk.
+public sealed class FileIndexUnitTests : IDisposable
+{
+ private readonly List _directories = [];
+
+ private string NewDirectory()
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "wiki-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+ _directories.Add(directory);
+ return directory;
+ }
+
+ private static IOptions OptionsFor(string directory) => Options.Create(new FileIndexOptions { Directory = directory });
+
+ private static float[] Unit(params float[] v)
+ {
+ var n = MathF.Sqrt(v.Sum(x => x * x));
+ return v.Select(x => x / n).ToArray();
+ }
+
+ [Fact]
+ public async Task VectorIndexFlushLeavesNoTempFileBehind()
+ {
+ var directory = NewDirectory();
+ var index = new FileVectorIndex(OptionsFor(directory));
+ await index.UpsertAsync([new Embedding("m", "a", "h1", Unit(1, 0, 0))], CancellationToken.None);
+ await index.FlushAsync(CancellationToken.None);
+
+ var indexDirectory = Path.Combine(directory, "index");
+ Assert.Empty(Directory.GetFiles(indexDirectory, "*.tmp"));
+ Assert.NotEmpty(Directory.GetFiles(indexDirectory, "*.bin"));
+ }
+
+ [Fact]
+ public async Task VectorIndexCorruptFileStartsEmptyAndHealthMentionsReset()
+ {
+ var directory = NewDirectory();
+ var indexDirectory = Path.Combine(directory, "index");
+ Directory.CreateDirectory(indexDirectory);
+ await File.WriteAllBytesAsync(Path.Combine(indexDirectory, "broken.bin"), [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]);
+
+ var index = new FileVectorIndex(OptionsFor(directory));
+ var state = await index.DescribeAsync("test-model", CancellationToken.None);
+ Assert.Equal(0, state.Count);
+
+ var health = await index.CheckAsync(CancellationToken.None);
+ Assert.True(health.Ok);
+ Assert.NotNull(health.Detail);
+ Assert.Contains("reset", health.Detail, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task CatalogFlushLeavesNoTempFileBehind()
+ {
+ var directory = NewDirectory();
+ var catalog = new FileDocumentCatalog(OptionsFor(directory));
+ await catalog.SetStateAsync(new CatalogState("abc", DateTimeOffset.UtcNow), CancellationToken.None);
+ await catalog.FlushAsync(CancellationToken.None);
+
+ Assert.Empty(Directory.GetFiles(directory, "*.tmp"));
+ Assert.True(File.Exists(Path.Combine(directory, "catalog.json")));
+ }
+
+ [Fact]
+ public async Task CatalogCorruptFileStartsEmptyAndHealthMentionsReset()
+ {
+ var directory = NewDirectory();
+ await File.WriteAllTextAsync(Path.Combine(directory, "catalog.json"), "{ not valid json");
+
+ var catalog = new FileDocumentCatalog(OptionsFor(directory));
+ var state = await catalog.GetStateAsync(CancellationToken.None);
+ Assert.Null(state.Commit);
+ Assert.Empty(await catalog.ListAsync(DocumentKind.Wiki, CancellationToken.None));
+
+ var health = await catalog.CheckAsync(CancellationToken.None);
+ Assert.True(health.Ok);
+ Assert.NotNull(health.Detail);
+ Assert.Contains("reset", health.Detail, StringComparison.OrdinalIgnoreCase);
+ }
+
+ public void Dispose()
+ {
+ foreach (var directory in _directories)
+ {
+ try { Directory.Delete(directory, recursive: true); }
+ catch (IOException) { }
+ catch (UnauthorizedAccessException) { }
+ }
+ }
+}
diff --git a/test/Wiki.Contracts/FileIndex/FileVectorIndexContractTests.cs b/test/Wiki.Contracts/FileIndex/FileVectorIndexContractTests.cs
new file mode 100644
index 0000000..4438f21
--- /dev/null
+++ b/test/Wiki.Contracts/FileIndex/FileVectorIndexContractTests.cs
@@ -0,0 +1,45 @@
+using Microsoft.Extensions.Options;
+using Wiki.Adapters.FileIndex;
+using Wiki.Core.Ports;
+
+namespace Wiki.Contracts.FileIndex;
+
+public sealed class FileVectorIndexContractTests : VectorIndexContract, IDisposable
+{
+ private readonly List _directories = [];
+ private readonly Dictionary _directoryOf = new(ReferenceEqualityComparer.Instance);
+
+ protected override Task CreateIndexAsync()
+ {
+ var directory = NewDirectory();
+ IVectorIndex index = new Wiki.Adapters.FileIndex.FileVectorIndex(Options.Create(new FileIndexOptions { Directory = directory }));
+ _directoryOf[index] = directory;
+ return Task.FromResult(index);
+ }
+
+ protected override Task ReopenAsync(IVectorIndex index)
+ {
+ var directory = _directoryOf[index];
+ IVectorIndex reopened = new Wiki.Adapters.FileIndex.FileVectorIndex(Options.Create(new FileIndexOptions { Directory = directory }));
+ _directoryOf[reopened] = directory;
+ return Task.FromResult(reopened);
+ }
+
+ private string NewDirectory()
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "wiki-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+ _directories.Add(directory);
+ return directory;
+ }
+
+ public void Dispose()
+ {
+ foreach (var directory in _directories)
+ {
+ try { Directory.Delete(directory, recursive: true); }
+ catch (IOException) { }
+ catch (UnauthorizedAccessException) { }
+ }
+ }
+}
diff --git a/test/Wiki.Contracts/Gitea/FakeGiteaHandler.cs b/test/Wiki.Contracts/Gitea/FakeGiteaHandler.cs
new file mode 100644
index 0000000..0437e8d
--- /dev/null
+++ b/test/Wiki.Contracts/Gitea/FakeGiteaHandler.cs
@@ -0,0 +1,202 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace Wiki.Contracts.Gitea;
+
+///
+/// A minimal Gitea API double, in front of an in-memory copy of .
+/// Reproduces the quirks the real Gitea note-store adapter has to tolerate: the tree listing pages
+/// with "tree": null past the last page, and may report truncated=true even on a page that turns
+/// out to be the last one with data. Blob shas are a SHA-1 hex of the file's content, so they are
+/// stable across repeated calls and distinct per distinct content, exactly what the note-store
+/// contract requires.
+///
+public sealed class FakeGiteaHandler : HttpMessageHandler
+{
+ private readonly Dictionary _files;
+ private readonly string _repo;
+ private readonly string _branch;
+ private readonly string _commit;
+ private readonly int _pageSize;
+
+ /// Every file created via POST .../contents/{path}, path -> decoded text, in call order.
+ public List<(string Path, string Content, string Branch, string Message)> Created { get; } = [];
+
+ public FakeGiteaHandler(string repository = "k.renner/notes", string branch = "main", int pageSize = 3)
+ {
+ _repo = repository;
+ _branch = branch;
+ _commit = VaultFixture.Commit;
+ _pageSize = pageSize;
+ _files = new Dictionary(VaultFixture.Files);
+ }
+
+ /// Adds a file to the fake as if it already existed at HEAD, e.g. to force the
+ /// fact sink into its -2 suffix path.
+ public void SeedFile(string path, string content) => _files[path] = content;
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ {
+ var path = request.RequestUri!.AbsolutePath;
+ var prefix = $"/api/v1/repos/{_repo}/";
+ if (!path.StartsWith(prefix, StringComparison.Ordinal))
+ return Task.FromResult(NotFound());
+
+ var rest = path[prefix.Length..];
+
+ if (request.Method == HttpMethod.Get && rest == $"branches/{_branch}")
+ return Task.FromResult(Json(new JsonObject
+ {
+ ["commit"] = new JsonObject
+ {
+ ["id"] = _commit,
+ ["timestamp"] = "2026-09-20T12:00:00Z",
+ },
+ }));
+
+ if (request.Method == HttpMethod.Get && rest.StartsWith($"git/trees/{_commit}", StringComparison.Ordinal))
+ return Task.FromResult(HandleTree(request.RequestUri));
+
+ if (request.Method == HttpMethod.Get && rest.StartsWith("raw/", StringComparison.Ordinal))
+ return Task.FromResult(HandleRaw(rest["raw/".Length..]));
+
+ if (request.Method == HttpMethod.Get && rest.StartsWith("contents/", StringComparison.Ordinal))
+ return Task.FromResult(HandleContentsExists(rest["contents/".Length..]));
+
+ if (request.Method == HttpMethod.Post && rest.StartsWith("contents/", StringComparison.Ordinal))
+ return HandleContentsCreateAsync(rest["contents/".Length..], request, ct);
+
+ return Task.FromResult(NotFound());
+ }
+
+ private HttpResponseMessage HandleTree(Uri requestUri)
+ {
+ var page = QueryInt(requestUri, "page", 1);
+ var entries = BuildEntries();
+ var start = (page - 1) * _pageSize;
+
+ var body = new JsonObject
+ {
+ ["sha"] = _commit,
+ ["total_count"] = entries.Count,
+ };
+
+ if (start >= entries.Count)
+ {
+ // Gitea answers "tree": null once you page past the end.
+ body["tree"] = null;
+ return Json(body);
+ }
+
+ var slice = entries.Skip(start).Take(_pageSize).ToList();
+ var tree = new JsonArray();
+ foreach (var e in slice)
+ {
+ tree.Add(new JsonObject
+ {
+ ["path"] = e.Path,
+ ["type"] = e.Type,
+ ["sha"] = e.Sha,
+ });
+ }
+ body["tree"] = tree;
+ // Gitea sets this even when this page happens to be the last one with data; callers must
+ // not use it to decide whether to stop.
+ body["truncated"] = true;
+ return Json(body);
+ }
+
+ private HttpResponseMessage HandleRaw(string encodedPathAndQuery)
+ {
+ var filePath = StripQuery(encodedPathAndQuery);
+ return _files.TryGetValue(filePath, out var content)
+ ? new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(content, Encoding.UTF8, "text/plain") }
+ : NotFound();
+ }
+
+ private HttpResponseMessage HandleContentsExists(string encodedPathAndQuery)
+ {
+ var filePath = StripQuery(encodedPathAndQuery);
+ return _files.ContainsKey(filePath)
+ ? Json(new JsonObject { ["path"] = filePath })
+ : NotFound();
+ }
+
+ private async Task HandleContentsCreateAsync(string encodedPathAndQuery, HttpRequestMessage request, CancellationToken ct)
+ {
+ var filePath = StripQuery(encodedPathAndQuery);
+ var payload = await request.Content!.ReadFromJsonAsync