diff --git a/src/Wiki.Adapters.Gitea/GiteaFactSink.cs b/src/Wiki.Adapters.Gitea/GiteaFactSink.cs
deleted file mode 100644
index a5ff3eb..0000000
--- a/src/Wiki.Adapters.Gitea/GiteaFactSink.cs
+++ /dev/null
@@ -1,157 +0,0 @@
-using System.Diagnostics;
-using System.Globalization;
-using System.Net;
-using System.Net.Http.Json;
-using System.Text;
-using Microsoft.Extensions.Options;
-using Wiki.Core.Model;
-using Wiki.Core.Ports;
-
-namespace Wiki.Adapters.Gitea;
-
-/// Deposits a fact as a new file under raw/ in Gitea. Never edits an existing file: a
-/// title that would collide with an existing path at HEAD gets a numbered suffix instead.
-public sealed class GiteaFactSink : IFactSink
-{
- private const int MaxSlugLength = 80;
- private const int MaxSuffixAttempts = 1000;
-
- private readonly HttpClient _http;
- private readonly GiteaOptions _options;
-
- public string Name => "gitea";
-
- public GiteaFactSink(HttpClient httpClient, IOptions options)
- {
- _http = httpClient;
- _options = options.Value;
- GiteaHttp.Configure(_http, _options);
- }
-
- public async Task AppendAsync(RawSource fact, Identity by, DateTimeOffset now, CancellationToken ct)
- {
- var errors = fact.Validate();
- if (errors.Count > 0)
- throw new ArgumentException($"Invalid raw source: {string.Join("; ", errors)}", nameof(fact));
-
- var datePrefix = now.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
- var slug = Slugify(fact.Title);
- var path = await ReservePathAsync(datePrefix, slug, ct).ConfigureAwait(false);
- var text = BuildFile(fact, by, now, datePrefix);
-
- var repo = GiteaUrls.EncodeSegments(_options.Repository);
- var encodedPath = GiteaUrls.EncodeSegments(path);
- var request = new
- {
- content = Convert.ToBase64String(Encoding.UTF8.GetBytes(text)),
- message = $"raw: {fact.Title}",
- branch = _options.Branch,
- };
- using var response = await _http.PostAsJsonAsync($"api/v1/repos/{repo}/contents/{encodedPath}", request, GiteaHttp.Json, ct)
- .ConfigureAwait(false);
- await GiteaHttp.EnsureSuccessAsync(response, ct).ConfigureAwait(false);
- var created = await response.Content.ReadFromJsonAsync(GiteaHttp.Json, ct).ConfigureAwait(false)
- ?? throw new InvalidOperationException("Gitea returned an empty body creating a file.");
- return new FactReceipt(path, created.Commit.Sha, now);
- }
-
- public async Task CheckAsync(CancellationToken ct)
- {
- var sw = Stopwatch.StartNew();
- try
- {
- var repo = GiteaUrls.EncodeSegments(_options.Repository);
- var branch = Uri.EscapeDataString(_options.Branch);
- var head = await GiteaHttp.GetJsonAsync(_http, $"api/v1/repos/{repo}/branches/{branch}", ct)
- .ConfigureAwait(false);
- return new AdapterHealth(Name, true, $"HEAD {head.Commit.Id}", sw.Elapsed, DateTimeOffset.UtcNow);
- }
- catch (Exception ex)
- {
- return new AdapterHealth(Name, false, ex.Message, sw.Elapsed, DateTimeOffset.UtcNow);
- }
- }
-
- /// The candidate path if free at HEAD, else the same with -2, -3, ... appended until one is free.
- private async Task ReservePathAsync(string datePrefix, string slug, CancellationToken ct)
- {
- var candidate = $"raw/{datePrefix}-{slug}.md";
- for (var attempt = 2; await ExistsAtHeadAsync(candidate, ct).ConfigureAwait(false); attempt++)
- {
- if (attempt > MaxSuffixAttempts)
- throw new InvalidOperationException($"Gitea has {MaxSuffixAttempts}+ raw files named like {datePrefix}-{slug}; giving up.");
- candidate = $"raw/{datePrefix}-{slug}-{attempt}.md";
- }
- return candidate;
- }
-
- private async Task ExistsAtHeadAsync(string path, CancellationToken ct)
- {
- var repo = GiteaUrls.EncodeSegments(_options.Repository);
- var encodedPath = GiteaUrls.EncodeSegments(path);
- var branch = Uri.EscapeDataString(_options.Branch);
- using var response = await _http.GetAsync($"api/v1/repos/{repo}/contents/{encodedPath}?ref={branch}", ct).ConfigureAwait(false);
- if (response.StatusCode == HttpStatusCode.NotFound) return false;
- await GiteaHttp.EnsureSuccessAsync(response, ct).ConfigureAwait(false);
- return true;
- }
-
- private static string BuildFile(RawSource fact, Identity by, DateTimeOffset now, string capturedDate)
- {
- var body = fact.Content;
- if (!body.EndsWith('\n')) body += "\n";
-
- var sb = new StringBuilder();
- sb.Append("---\n");
- sb.Append("title: ").Append(YamlScalar(fact.Title)).Append('\n');
- sb.Append("type: raw\n");
- sb.Append("source: ").Append(YamlScalar(fact.Source)).Append('\n');
- sb.Append("captured: ").Append(capturedDate).Append('\n');
- sb.Append("captured_by: ").Append(by.Name).Append('\n');
- sb.Append("observed: ").Append(fact.ObservedAt.ToString("yyyy-MM-ddTHH:mm:ssK", CultureInfo.InvariantCulture)).Append('\n');
- sb.Append("tags: [").Append(string.Join(", ", fact.Tags)).Append("]\n");
- sb.Append("---\n");
- sb.Append('\n');
- sb.Append(body);
- return sb.ToString();
- }
-
- /// Quotes a scalar that would otherwise be ambiguous or wrong in flow-less YAML: a colon
- /// or a '#' can start a mapping value or a comment mid-line, and a leading quote needs escaping.
- private static string YamlScalar(string value)
- {
- if (!value.Contains(':') && !value.Contains('#') && !value.StartsWith('"') && !value.StartsWith('\''))
- return value;
- return $"\"{value.Replace("\"", "\\\"")}\"";
- }
-
- private static string Slugify(string title)
- {
- var sb = new StringBuilder(title.Length);
- foreach (var ch in title.ToLowerInvariant())
- {
- if (ch is (>= 'a' and <= 'z') or (>= '0' and <= '9')) sb.Append(ch);
- else sb.Append('-');
- }
-
- var collapsed = new StringBuilder(sb.Length);
- var lastWasDash = false;
- foreach (var ch in sb.ToString())
- {
- if (ch == '-')
- {
- if (lastWasDash) continue;
- lastWasDash = true;
- }
- else
- {
- lastWasDash = false;
- }
- collapsed.Append(ch);
- }
-
- var slug = collapsed.ToString().Trim('-');
- if (slug.Length > MaxSlugLength) slug = slug[..MaxSlugLength].Trim('-');
- return slug.Length == 0 ? "untitled" : slug;
- }
-}
diff --git a/src/Wiki.Adapters.Gitea/GiteaHttp.cs b/src/Wiki.Adapters.Gitea/GiteaHttp.cs
index bf43add..fd7fa50 100644
--- a/src/Wiki.Adapters.Gitea/GiteaHttp.cs
+++ b/src/Wiki.Adapters.Gitea/GiteaHttp.cs
@@ -3,7 +3,7 @@ using System.Text.Json;
namespace Wiki.Adapters.Gitea;
-/// HTTP plumbing shared by and :
+/// HTTP plumbing shared by :
/// client setup, JSON decoding and turning a failed response into a message worth reading.
internal static class GiteaHttp
{
diff --git a/src/Wiki.Adapters.Gitea/GiteaNoteStore.cs b/src/Wiki.Adapters.Gitea/GiteaRepository.cs
similarity index 69%
rename from src/Wiki.Adapters.Gitea/GiteaNoteStore.cs
rename to src/Wiki.Adapters.Gitea/GiteaRepository.cs
index 7e83748..48676de 100644
--- a/src/Wiki.Adapters.Gitea/GiteaNoteStore.cs
+++ b/src/Wiki.Adapters.Gitea/GiteaRepository.cs
@@ -1,4 +1,7 @@
using System.Diagnostics;
+using System.Net;
+using System.Net.Http.Json;
+using System.Text;
using Microsoft.Extensions.Options;
using Wiki.Core.Model;
using Wiki.Core.Parsing;
@@ -6,9 +9,11 @@ using Wiki.Core.Ports;
namespace Wiki.Adapters.Gitea;
-/// Reads the vault straight from a Gitea repository: HEAD, a recursive tree listing and raw
-/// file contents, all at the commit the listing was taken from.
-public sealed class GiteaNoteStore : INoteStore
+///
+/// One Gitea repository as the vault's storage: HEAD, a recursive tree listing, raw file contents
+/// at a commit, and creating a new file on the branch. Knows paths and text, nothing about notes.
+///
+public sealed class GiteaRepository : INoteStore
{
// Gitea answers "tree": null past the last page, and may mark the last page truncated=true even
// though nothing follows; stop on a short/empty page or once total_count is reached either way.
@@ -20,7 +25,7 @@ public sealed class GiteaNoteStore : INoteStore
public string Name => "gitea";
- public GiteaNoteStore(HttpClient httpClient, IOptions options)
+ public GiteaRepository(HttpClient httpClient, IOptions options)
{
_http = httpClient;
_options = options.Value;
@@ -53,8 +58,7 @@ public sealed class GiteaNoteStore : INoteStore
}
seen += entries.Count;
// total_count is the reliable signal (confirmed live: Gitea reports it regardless of the
- // per_page requested); a short page only means "done" when total_count isn't available,
- // since a server may cap the effective page size below what was asked for.
+ // per_page requested); a short page only means "done" when total_count isn't available.
var done = entries.Count == 0
|| (tree.TotalCount > 0 ? seen >= tree.TotalCount : entries.Count < PageSize);
if (done) break;
@@ -74,6 +78,27 @@ public sealed class GiteaNoteStore : INoteStore
return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
}
+ /// POST contents: Gitea answers 422 when the path already exists on the branch.
+ public async Task CreateAsync(string path, string text, string message, CancellationToken ct)
+ {
+ var repo = GiteaUrls.EncodeSegments(_options.Repository);
+ var encodedPath = GiteaUrls.EncodeSegments(path);
+ var request = new
+ {
+ content = Convert.ToBase64String(Encoding.UTF8.GetBytes(text)),
+ message,
+ branch = _options.Branch,
+ };
+ using var response = await _http.PostAsJsonAsync($"api/v1/repos/{repo}/contents/{encodedPath}", request, GiteaHttp.Json, ct)
+ .ConfigureAwait(false);
+ if (response.StatusCode == HttpStatusCode.UnprocessableEntity)
+ throw new DocumentExistsException(path);
+ await GiteaHttp.EnsureSuccessAsync(response, ct).ConfigureAwait(false);
+ var created = await response.Content.ReadFromJsonAsync(GiteaHttp.Json, ct).ConfigureAwait(false)
+ ?? throw new InvalidOperationException("Gitea returned an empty body creating a file.");
+ return new StoreWrite(path, created.Commit.Sha);
+ }
+
public async Task CheckAsync(CancellationToken ct)
{
var sw = Stopwatch.StartNew();
diff --git a/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs b/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs
index c2a8ff6..f9afdb2 100644
--- a/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs
+++ b/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs
@@ -1,24 +1,24 @@
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
using Wiki.Core.Ports;
namespace Wiki.Adapters.Gitea;
public static class ServiceCollectionExtensions
{
- /// Registers the Gitea-backed and , each
- /// with its own managed HttpClient from . Reads retry transient
- /// failures; pooled connections are recycled so a stale keep-alive is not reused for long.
- public static IServiceCollection AddGiteaAdapters(this IServiceCollection services, Action configure)
+ private const string ClientName = "gitea";
+
+ /// Registers as the . Reads retry
+ /// transient failures; pooled connections are recycled so a stale keep-alive is not reused for long.
+ public static IServiceCollection AddGiteaNoteStore(this IServiceCollection services, Action configure)
{
services.Configure(configure);
- services.AddHttpClient()
+ services.AddHttpClient(ClientName)
.AddHttpMessageHandler(() => new TransientRetryHandler())
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) });
- services.AddHttpClient()
- .AddHttpMessageHandler(() => new TransientRetryHandler())
- .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) });
- services.AddTransient(sp => sp.GetRequiredService());
- services.AddTransient(sp => sp.GetRequiredService());
+ services.AddSingleton(sp => new GiteaRepository(
+ sp.GetRequiredService().CreateClient(ClientName),
+ sp.GetRequiredService>()));
return services;
}
}
diff --git a/src/Wiki.Application/Facts/DepositFactUseCase.cs b/src/Wiki.Application/Facts/DepositFactUseCase.cs
new file mode 100644
index 0000000..7a7f52d
--- /dev/null
+++ b/src/Wiki.Application/Facts/DepositFactUseCase.cs
@@ -0,0 +1,94 @@
+using System.Globalization;
+using System.Text;
+using Wiki.Core.Model;
+using Wiki.Core.Ports;
+
+namespace Wiki.Application.Facts;
+
+///
+/// Turns a fact into a raw source note and stores it: provenance frontmatter in the vault's
+/// shape, a dated slug path under raw/, a numbered suffix when the path is taken. The store only
+/// ever sees a path and text.
+///
+public sealed class DepositFactUseCase(INoteStore store, TimeProvider clock)
+{
+ private const int MaxSlugLength = 80;
+ private const int MaxSuffixAttempts = 1000;
+
+ public async Task DepositAsync(RawSource fact, Identity by, CancellationToken ct)
+ {
+ var errors = fact.Validate();
+ if (errors.Count > 0)
+ throw new ArgumentException($"Invalid raw source: {string.Join("; ", errors)}", nameof(fact));
+
+ var now = clock.GetUtcNow();
+ var datePrefix = now.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ var slug = Slugify(fact.Title);
+ var text = BuildNote(fact, by, datePrefix);
+ var message = $"raw: {fact.Title}";
+
+ var path = $"raw/{datePrefix}-{slug}.md";
+ for (var attempt = 2; ; attempt++)
+ {
+ try
+ {
+ var written = await store.CreateAsync(path, text, message, ct);
+ return new FactReceipt(written.Path, written.Commit, now);
+ }
+ catch (DocumentExistsException) when (attempt <= MaxSuffixAttempts)
+ {
+ path = $"raw/{datePrefix}-{slug}-{attempt}.md";
+ }
+ }
+ }
+
+ public static string BuildNote(RawSource fact, Identity by, string capturedDate)
+ {
+ var body = fact.Content;
+ if (!body.EndsWith('\n')) body += "\n";
+
+ var sb = new StringBuilder();
+ sb.Append("---\n");
+ sb.Append("title: ").Append(YamlScalar(fact.Title)).Append('\n');
+ sb.Append("type: raw\n");
+ sb.Append("source: ").Append(YamlScalar(fact.Source)).Append('\n');
+ sb.Append("captured: ").Append(capturedDate).Append('\n');
+ sb.Append("captured_by: ").Append(by.Name).Append('\n');
+ sb.Append("observed: ").Append(fact.ObservedAt.ToString("yyyy-MM-ddTHH:mm:ssK", CultureInfo.InvariantCulture)).Append('\n');
+ sb.Append("tags: [").Append(string.Join(", ", fact.Tags)).Append("]\n");
+ sb.Append("---\n");
+ sb.Append('\n');
+ sb.Append(body);
+ return sb.ToString();
+ }
+
+ /// Quotes a scalar that would otherwise be ambiguous in flow-less YAML.
+ private static string YamlScalar(string value)
+ {
+ if (!value.Contains(':') && !value.Contains('#') && !value.StartsWith('"') && !value.StartsWith('\''))
+ return value;
+ return $"\"{value.Replace("\"", "\\\"")}\"";
+ }
+
+ public static string Slugify(string title)
+ {
+ var sb = new StringBuilder(title.Length);
+ var lastWasDash = false;
+ foreach (var ch in title.ToLowerInvariant())
+ {
+ if (ch is (>= 'a' and <= 'z') or (>= '0' and <= '9'))
+ {
+ sb.Append(ch);
+ lastWasDash = false;
+ }
+ else if (!lastWasDash)
+ {
+ sb.Append('-');
+ lastWasDash = true;
+ }
+ }
+ var slug = sb.ToString().Trim('-');
+ if (slug.Length > MaxSlugLength) slug = slug[..MaxSlugLength].Trim('-');
+ return slug.Length == 0 ? "untitled" : slug;
+ }
+}
diff --git a/src/Wiki.Core/Model/Retrieval.cs b/src/Wiki.Core/Model/Retrieval.cs
index ff61934..f3cdc70 100644
--- a/src/Wiki.Core/Model/Retrieval.cs
+++ b/src/Wiki.Core/Model/Retrieval.cs
@@ -14,3 +14,12 @@ public sealed record IndexState(string ModelId, IReadOnlyDictionaryHead of the note store: the commit the listing is taken from.
public sealed record StoreHead(string Commit, DateTimeOffset? CommittedAt);
+
+/// Result of creating a document in the store.
+public sealed record StoreWrite(string Path, string Commit);
+
+/// The path is already taken; the caller picks another.
+public sealed class DocumentExistsException(string path) : Exception($"document already exists: {path}")
+{
+ public string Path { get; } = path;
+}
diff --git a/src/Wiki.Core/Ports/Ports.cs b/src/Wiki.Core/Ports/Ports.cs
index 1c0a039..9378bcd 100644
--- a/src/Wiki.Core/Ports/Ports.cs
+++ b/src/Wiki.Core/Ports/Ports.cs
@@ -9,7 +9,10 @@ public interface IAdapterHealth
Task CheckAsync(CancellationToken ct);
}
-/// Where the vault's documents are read from.
+///
+/// Where the vault's documents live: read at a head, and create a new document. What a document
+/// means (a note, a raw source, a report) is the application's business; the store moves text.
+///
public interface INoteStore : IAdapterHealth
{
/// The current head; a listing is always taken at a head.
@@ -20,12 +23,9 @@ public interface INoteStore : IAdapterHealth
/// The document's text at that head.
Task ReadAsync(DocumentRef reference, string commit, CancellationToken ct);
-}
-/// Where a deposited fact goes: one new raw source, never an edit of an existing file.
-public interface IFactSink : IAdapterHealth
-{
- Task AppendAsync(RawSource fact, Identity by, DateTimeOffset now, CancellationToken ct);
+ /// Creates a new document at the path on the current head. Throws when the path is taken.
+ Task CreateAsync(string path, string text, string message, CancellationToken ct);
}
/// Text to vector. One instance per configured model.
diff --git a/src/Wiki.Host/Program.cs b/src/Wiki.Host/Program.cs
index 17fc409..ecf0d9e 100644
--- a/src/Wiki.Host/Program.cs
+++ b/src/Wiki.Host/Program.cs
@@ -18,7 +18,7 @@ builder.Services.AddSingleton(wiki);
builder.Services.AddSingleton(TimeProvider.System);
// ---- adapters, selected by configuration ------------------------------------------------
-builder.Services.AddGiteaAdapters(o =>
+builder.Services.AddGiteaNoteStore(o =>
{
o.BaseUrl = wiki.NoteStore.Gitea.BaseUrl;
o.Repository = wiki.NoteStore.Gitea.Repository;
@@ -47,14 +47,18 @@ foreach (var (name, model) in wiki.Embeddings.Models)
builder.Services.AddSingleton>(sp =>
wiki.Embeddings.Models.Keys.Select(sp.GetRequiredKeyedService).ToList());
builder.Services.AddSingleton(sp => sp.GetRequiredKeyedService(wiki.Embeddings.Active));
+// One adapter may serve several ports (Gitea is note store and fact sink); list it once.
builder.Services.AddSingleton>(sp =>
-[
- sp.GetRequiredService(),
- sp.GetRequiredService(),
- sp.GetRequiredService(),
- sp.GetRequiredService(),
- .. sp.GetRequiredService>(),
-]);
+{
+ List all =
+ [
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ .. sp.GetRequiredService>(),
+ ];
+ return all.Distinct().ToList();
+});
// ---- use cases ---------------------------------------------------------------------------
builder.Services.AddSingleton(new SyncOptions());
diff --git a/test/Wiki.Application.Tests/DepositFactUseCaseTests.cs b/test/Wiki.Application.Tests/DepositFactUseCaseTests.cs
new file mode 100644
index 0000000..4ba53be
--- /dev/null
+++ b/test/Wiki.Application.Tests/DepositFactUseCaseTests.cs
@@ -0,0 +1,71 @@
+using Microsoft.Extensions.Time.Testing;
+using Wiki.Application.Facts;
+using Wiki.Core.Model;
+
+namespace Wiki.Application.Tests;
+
+public class DepositFactUseCaseTests
+{
+ private static readonly RawSource Fact = new(
+ Title: "UDM intercepts DNS: a follow-up",
+ Source: "https://example.com/thread#5",
+ Content: "The UDM answers every UDP/53 query itself, independent of what upstream is configured elsewhere.",
+ ObservedAt: new DateTimeOffset(2026, 9, 20, 14, 30, 0, TimeSpan.Zero),
+ Tags: ["dns", "udm"]);
+
+ private static readonly Identity By = new("agent-x", IdentityKind.Machine);
+ private static readonly DateTimeOffset Now = new(2026, 9, 23, 8, 0, 0, TimeSpan.Zero);
+ private const string ExpectedPath = "raw/2026-09-23-udm-intercepts-dns-a-follow-up.md";
+
+ private const string ExpectedText =
+ "---\n" +
+ "title: \"UDM intercepts DNS: a follow-up\"\n" +
+ "type: raw\n" +
+ "source: \"https://example.com/thread#5\"\n" +
+ "captured: 2026-09-23\n" +
+ "captured_by: agent-x\n" +
+ "observed: 2026-09-20T14:30:00+00:00\n" +
+ "tags: [dns, udm]\n" +
+ "---\n" +
+ "\n" +
+ "The UDM answers every UDP/53 query itself, independent of what upstream is configured elsewhere.\n";
+
+ private readonly FakeStore _store = new();
+ private DepositFactUseCase UseCase => new(_store, new FakeTimeProvider(Now));
+
+ [Fact]
+ public async Task WritesTheRawNoteAtTheDatedSlugPath()
+ {
+ var receipt = await UseCase.DepositAsync(Fact, By, CancellationToken.None);
+ Assert.Equal(ExpectedPath, receipt.Path);
+ Assert.Equal(Now, receipt.StoredAt);
+ Assert.Equal(ExpectedText, _store.Files[ExpectedPath]);
+ Assert.Equal(("raw: UDM intercepts DNS: a follow-up", ExpectedPath), _store.Created.Single());
+ }
+
+ [Fact]
+ public async Task NumbersThePathWhileItCollides()
+ {
+ _store.Files[ExpectedPath] = "already here";
+ _store.Files["raw/2026-09-23-udm-intercepts-dns-a-follow-up-2.md"] = "also here";
+ var receipt = await UseCase.DepositAsync(Fact, By, CancellationToken.None);
+ Assert.Equal("raw/2026-09-23-udm-intercepts-dns-a-follow-up-3.md", receipt.Path);
+ }
+
+ [Theory]
+ [InlineData("", "source", "content long enough to pass the minimum length check, easily.")]
+ [InlineData("title", "", "content long enough to pass the minimum length check, easily.")]
+ [InlineData("title", "source", "too short")]
+ public async Task RejectsAnInvalidFactBeforeTouchingTheStore(string title, string source, string content)
+ {
+ var invalid = new RawSource(title, source, content, Now, []);
+ await Assert.ThrowsAsync(() => UseCase.DepositAsync(invalid, By, CancellationToken.None));
+ Assert.Empty(_store.Created);
+ }
+
+ [Theory]
+ [InlineData("Talos v1.13.10: aes256k / kernel 6.18!", "talos-v1-13-10-aes256k-kernel-6-18")]
+ [InlineData(" ", "untitled")]
+ [InlineData("Ünïcödé näme", "n-c-d-n-me")]
+ public void SlugifiesTitles(string title, string slug) => Assert.Equal(slug, DepositFactUseCase.Slugify(title));
+}
diff --git a/test/Wiki.Application.Tests/Fakes.cs b/test/Wiki.Application.Tests/Fakes.cs
index 2d591a5..2c24003 100644
--- a/test/Wiki.Application.Tests/Fakes.cs
+++ b/test/Wiki.Application.Tests/Fakes.cs
@@ -34,6 +34,16 @@ public sealed class FakeStore : INoteStore
return Task.FromResult(Files[reference.Path]);
}
+ public List<(string Message, string Path)> Created { get; } = [];
+
+ public Task CreateAsync(string path, string text, string message, CancellationToken ct)
+ {
+ if (Files.ContainsKey(path)) throw new DocumentExistsException(path);
+ Files[path] = text;
+ Created.Add((message, path));
+ return Task.FromResult(new StoreWrite(path, Convert.ToHexStringLower(SHA1.HashData(Encoding.UTF8.GetBytes(text)))));
+ }
+
public Task CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
}
diff --git a/test/Wiki.Application.Tests/Wiki.Application.Tests.csproj b/test/Wiki.Application.Tests/Wiki.Application.Tests.csproj
index 9d03af8..e190d11 100644
--- a/test/Wiki.Application.Tests/Wiki.Application.Tests.csproj
+++ b/test/Wiki.Application.Tests/Wiki.Application.Tests.csproj
@@ -10,6 +10,7 @@
+
diff --git a/test/Wiki.Contracts/Gitea/GiteaFactSinkTests.cs b/test/Wiki.Contracts/Gitea/GiteaFactSinkTests.cs
deleted file mode 100644
index 96d2042..0000000
--- a/test/Wiki.Contracts/Gitea/GiteaFactSinkTests.cs
+++ /dev/null
@@ -1,121 +0,0 @@
-using System.Security.Cryptography;
-using System.Text;
-using Microsoft.Extensions.Options;
-using Wiki.Adapters.Gitea;
-using Wiki.Core.Model;
-
-namespace Wiki.Contracts.Gitea;
-
-public sealed class GiteaFactSinkTests
-{
- private const string Repository = "k.renner/notes";
- private const string Branch = "main";
-
- private static readonly RawSource Fact = new(
- Title: "UDM intercepts DNS: a follow-up",
- Source: "https://example.com/thread#5",
- Content: "The UDM answers every UDP/53 query itself, independent of what upstream is configured elsewhere.",
- ObservedAt: new DateTimeOffset(2026, 9, 20, 14, 30, 0, TimeSpan.Zero),
- Tags: ["dns", "udm"]);
-
- private static readonly Identity By = new("agent-x", IdentityKind.Machine);
- private static readonly DateTimeOffset Now = new(2026, 9, 23, 8, 0, 0, TimeSpan.Zero);
-
- private const string ExpectedPath = "raw/2026-09-23-udm-intercepts-dns-a-follow-up.md";
-
- private static readonly string ExpectedText =
- "---\n" +
- "title: \"UDM intercepts DNS: a follow-up\"\n" +
- "type: raw\n" +
- "source: \"https://example.com/thread#5\"\n" +
- "captured: 2026-09-23\n" +
- "captured_by: agent-x\n" +
- "observed: 2026-09-20T14:30:00+00:00\n" +
- "tags: [dns, udm]\n" +
- "---\n" +
- "\n" +
- "The UDM answers every UDP/53 query itself, independent of what upstream is configured elsewhere.\n";
-
- private static (FakeGiteaHandler Handler, GiteaFactSink Sink) CreateSink()
- {
- var handler = new FakeGiteaHandler(Repository, Branch, pageSize: 3);
- var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://gitea.test") };
- var options = Options.Create(new GiteaOptions
- {
- BaseUrl = "https://gitea.test",
- Repository = Repository,
- Branch = Branch,
- Token = "test-token",
- });
- return (handler, new GiteaFactSink(httpClient, options));
- }
-
- [Fact]
- public async Task WritesTheExpectedPathAndFrontmatter()
- {
- var (handler, sink) = CreateSink();
-
- var receipt = await sink.AppendAsync(Fact, By, Now, CancellationToken.None);
-
- Assert.Equal(ExpectedPath, receipt.Path);
- Assert.Equal(Now, receipt.StoredAt);
-
- var created = Assert.Single(handler.Created);
- Assert.Equal(ExpectedPath, created.Path);
- Assert.Equal(ExpectedText, created.Content);
- Assert.Equal(Branch, created.Branch);
- }
-
- [Fact]
- public async Task ReturnsTheCommitShaFromTheResponse()
- {
- var (_, sink) = CreateSink();
-
- var receipt = await sink.AppendAsync(Fact, By, Now, CancellationToken.None);
-
- Assert.Equal(Sha1Hex(ExpectedText), receipt.Commit);
- }
-
- [Fact]
- public async Task AppendsANumberedSuffixWhenThePathAlreadyExists()
- {
- var (handler, sink) = CreateSink();
- handler.SeedFile(ExpectedPath, "already here");
-
- var receipt = await sink.AppendAsync(Fact, By, Now, CancellationToken.None);
-
- Assert.Equal("raw/2026-09-23-udm-intercepts-dns-a-follow-up-2.md", receipt.Path);
- }
-
- [Fact]
- public async Task KeepsNumberingWhileCandidatesCollide()
- {
- var (handler, sink) = CreateSink();
- handler.SeedFile(ExpectedPath, "already here");
- handler.SeedFile("raw/2026-09-23-udm-intercepts-dns-a-follow-up-2.md", "also here");
-
- var receipt = await sink.AppendAsync(Fact, By, Now, CancellationToken.None);
-
- Assert.Equal("raw/2026-09-23-udm-intercepts-dns-a-follow-up-3.md", receipt.Path);
- }
-
- [Theory]
- [InlineData("", "source", "content long enough to pass the minimum length check, easily.")]
- [InlineData("title", "", "content long enough to pass the minimum length check, easily.")]
- [InlineData("title", "source", "too short")]
- public async Task RejectsAnInvalidRawSourceBeforeCallingGitea(string title, string source, string content)
- {
- var (handler, sink) = CreateSink();
- var invalid = new RawSource(title, source, content, Now, []);
-
- await Assert.ThrowsAsync(() => sink.AppendAsync(invalid, By, Now, CancellationToken.None));
-
- Assert.Empty(handler.Created);
- }
-
- private static string Sha1Hex(string content)
- {
- var bytes = SHA1.HashData(Encoding.UTF8.GetBytes(content));
- return Convert.ToHexStringLower(bytes);
- }
-}
diff --git a/test/Wiki.Contracts/Gitea/GiteaNoteStoreContractTests.cs b/test/Wiki.Contracts/Gitea/GiteaNoteStoreContractTests.cs
index 59853af..71966ff 100644
--- a/test/Wiki.Contracts/Gitea/GiteaNoteStoreContractTests.cs
+++ b/test/Wiki.Contracts/Gitea/GiteaNoteStoreContractTests.cs
@@ -20,6 +20,6 @@ public sealed class GiteaNoteStoreContractTests : NoteStoreContract
Branch = Branch,
Token = "test-token",
});
- return Task.FromResult(new GiteaNoteStore(httpClient, options));
+ return Task.FromResult(new GiteaRepository(httpClient, options));
}
}
diff --git a/test/Wiki.Contracts/NoteStoreContract.cs b/test/Wiki.Contracts/NoteStoreContract.cs
index 86fc37d..3fed37f 100644
--- a/test/Wiki.Contracts/NoteStoreContract.cs
+++ b/test/Wiki.Contracts/NoteStoreContract.cs
@@ -57,6 +57,25 @@ public abstract class NoteStoreContract
}
}
+ [Fact]
+ public async Task CreatesANewDocumentAndRefusesAnExistingPath()
+ {
+ var store = await CreateStoreAsync();
+ var written = await store.CreateAsync("raw/2026-09-24-new.md", "---\ntitle: New\n---\n\nText.\n", "raw: New", CancellationToken.None);
+ Assert.Equal("raw/2026-09-24-new.md", written.Path);
+ Assert.False(string.IsNullOrWhiteSpace(written.Commit));
+
+ var head = await store.GetHeadAsync(CancellationToken.None);
+ var refs = await store.ListAsync(head.Commit, VaultFixture.AllKinds, CancellationToken.None);
+ var created = Assert.Single(refs, r => r.Path == "raw/2026-09-24-new.md");
+ Assert.Equal("---\ntitle: New\n---\n\nText.\n", await store.ReadAsync(created, head.Commit, CancellationToken.None));
+
+ await Assert.ThrowsAsync(() =>
+ store.CreateAsync("raw/2026-09-24-new.md", "other", "raw: New", CancellationToken.None));
+ await Assert.ThrowsAsync(() =>
+ store.CreateAsync("wiki/udm-dns.md", "other", "x", CancellationToken.None));
+ }
+
[Fact]
public async Task HealthReportsOk()
{
diff --git a/test/Wiki.Host.Tests/EndpointTests.cs b/test/Wiki.Host.Tests/EndpointTests.cs
index 1a74703..d6b9827 100644
--- a/test/Wiki.Host.Tests/EndpointTests.cs
+++ b/test/Wiki.Host.Tests/EndpointTests.cs
@@ -32,8 +32,6 @@ public sealed class WikiHostFactory : WebApplicationFactory
{
services.RemoveAll();
services.AddSingleton(Store);
- services.RemoveAll();
- services.AddSingleton(new FakeFactSink());
// The embedding model registered under the active name is replaced by a deterministic fake.
services.RemoveAll(typeof(IEmbeddingModel));
var fake = new FakeEmbeddings("bge-m3");
diff --git a/test/Wiki.Host.Tests/Fakes.cs b/test/Wiki.Host.Tests/Fakes.cs
index 764cba8..cf29fcf 100644
--- a/test/Wiki.Host.Tests/Fakes.cs
+++ b/test/Wiki.Host.Tests/Fakes.cs
@@ -33,14 +33,14 @@ public sealed class FakeStore : INoteStore
}
public Task ReadAsync(DocumentRef reference, string commit, CancellationToken ct) => Task.FromResult(Files[reference.Path]);
- public Task CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
-}
-public sealed class FakeFactSink : IFactSink
-{
- public string Name => "fake-facts";
- public Task AppendAsync(RawSource fact, Identity by, DateTimeOffset now, CancellationToken ct) =>
- Task.FromResult(new FactReceipt("raw/fake.md", "c", now));
+ public Task CreateAsync(string path, string text, string message, CancellationToken ct)
+ {
+ if (Files.ContainsKey(path)) throw new DocumentExistsException(path);
+ Files[path] = text;
+ return Task.FromResult(new StoreWrite(path, "c"));
+ }
+
public Task CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
}