diff --git a/CHANGELOG.md b/CHANGELOG.md index e4365b1..72373cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to DevBrain are tracked in this file. Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +Two new document-editing tools add a safe preview/apply workflow for exact text replacements. This keeps DevBrain's whole-document storage model intact while giving AI callers a deterministic way to edit without stale overwrites. + +### Added +- **`PreviewEditDocument(key, oldText, newText, expectedOccurrences?, caseSensitive?, project?)`** — previews a literal text replacement without writing. Returns match count, before/after snippets, and the current `contentHash` for the caller to feed into apply. +- **`ApplyEditDocument(key, oldText, newText, expectedContentHash, expectedOccurrences?, caseSensitive?, project?)`** — applies the same literal replacement only if the stored document still matches the preview hash. Refuses ambiguous or stale edits. +- **`DocumentEditService`** — shared edit-planning/execution layer that finds literal matches, builds preview snippets, and computes the final whole-document replacement payload. +- **`ConditionalWriteResult` + `IDocumentStore.ReplaceIfHashMatchesAsync(...)`** — conditional write path used by apply to avoid overwriting a document that changed after preview. +- **`ContentHashing` helper** — centralizes content-hash computation and normalization so compare/edit flows share the same hash semantics. + +### Changed +- `README.md` now documents the new edit tools and the recommended preview/apply workflow. +- `docs/seed/ref-devbrain-usage.md` now teaches AI callers how to edit existing documents safely using `PreviewEditDocument` followed by `ApplyEditDocument`. + ## [1.7.0] — 2026-04-12 Two new read-only tools that let callers check whether a document has changed without pulling the full content into context. Every write now stamps a SHA-256 `contentHash` and `contentLength` on the document, enabling cheap staleness checks and import-or-skip decisions. diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..765346e --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/README.md b/README.md index 150d996..7d1cb0a 100644 --- a/README.md +++ b/README.md @@ -169,10 +169,47 @@ All tools accept an optional `project` parameter (defaults to `"default"`) to is | `GetDocument` | `key` (required), `project` | Retrieve a document by key | | `GetDocumentMetadata` | `key` (required), `project` | Retrieve document metadata (tags, timestamps, contentHash, contentLength) without the content body | | `CompareDocument` | `key` (required), `content` or `contentHash` (one required), `project` | Check whether candidate content matches a stored document by SHA-256 hash | +| `PreviewEditDocument` | `key` (required), `oldText` (required), `newText` (required), `expectedOccurrences`, `caseSensitive`, `project` | Preview a literal text replacement without writing; returns match count, before/after preview, and the current content hash | +| `ApplyEditDocument` | `key` (required), `oldText` (required), `newText` (required), `expectedContentHash` (required), `expectedOccurrences`, `caseSensitive`, `project` | Apply a literal text replacement only if the document still matches the preview hash | | `ListDocuments` | `prefix`, `project` | List document keys, optionally filtered by prefix | | `SearchDocuments` | `query` (required), `project` | Substring search across keys and content | | `DeleteDocument` | `key` (required), `project` | Delete a document by key. Idempotent on missing keys. | +### Editing Documents Safely + +DevBrain still stores documents as whole values, but it now supports a safe two-step edit flow for exact text changes: + +1. Call `PreviewEditDocument` with the literal `oldText` and `newText` +2. Inspect the returned `matchCount`, preview snippets, and `currentContentHash` +3. Call `ApplyEditDocument` with the same edit inputs and `expectedContentHash` + +Why two steps: + +- **Ambiguity guard.** Preview refuses edits when the number of matches differs from `expectedOccurrences` (defaults to `1`). +- **Concurrency guard.** Apply fails if the stored content hash changed after preview, preventing stale overwrites. +- **Agent-friendly ergonomics.** Exact snippet replacement is more reliable than offsets or regex for most AI callers. + +Example: + +```text +PreviewEditDocument( + key="state:current", + project="devbrain", + oldText="Status: draft", + newText="Status: in progress" +) +``` + +```text +ApplyEditDocument( + key="state:current", + project="devbrain", + oldText="Status: draft", + newText="Status: in progress", + expectedContentHash="" +) +``` + ### When to use Append vs Chunked Both tools exist to work around the LLM-client per-turn output budget, but they solve different problems: diff --git a/docs/seed/ref-devbrain-usage.md b/docs/seed/ref-devbrain-usage.md index ec9e8ea..4f8ae24 100644 --- a/docs/seed/ref-devbrain-usage.md +++ b/docs/seed/ref-devbrain-usage.md @@ -4,7 +4,7 @@ DevBrain organizes documents by project. If a query returns no results, you are likely searching the wrong project scope. -**Always specify the project parameter explicitly.** The default project ("default") is empty for most use cases. +**Always specify the project parameter explicitly.** Use `default` for shared reference docs or quick sandboxing, and use named projects like `devbrain` for real project state. ## Known Projects - `devbrain` — DevBrain's own documentation, architecture, sprint docs, backlog, known issues @@ -68,6 +68,46 @@ Returns `{ found, match, storedContentHash, candidateHash, ... }`. The server ha This pattern avoids pulling the full stored document into context just to decide whether to write. +## Editing Existing Documents Safely — Preview and Apply + +For exact text edits, use the guarded two-step flow instead of doing a blind full overwrite. + +### Preview an exact edit first +``` +PreviewEditDocument( + key: "ref:devbrain-usage", + oldText: "old phrase", + newText: "new phrase", + project: "default", + expectedOccurrences: 1 +) +``` +Preview returns whether the edit is possible, how many matches were found, whether the request is ambiguous, and the current `contentHash` to use when applying. + +### Apply only against the previewed version +``` +ApplyEditDocument( + key: "ref:devbrain-usage", + oldText: "old phrase", + newText: "new phrase", + project: "default", + expectedOccurrences: 1, + expectedContentHash: "" +) +``` +If the document changed after preview, apply refuses the write and tells you to preview again. + +### Recommended edit workflow +1. Call `PreviewEditDocument` +2. Confirm `WouldReplace: true` and the `MatchCount` is what you expected +3. Pass the returned `CurrentContentHash` into `ApplyEditDocument` +4. If apply reports that the document changed since preview, re-run preview and try again + +### Useful guardrails +- Use `expectedOccurrences` to refuse zero-match or multi-match edits unless they are intentional +- Use `caseSensitive: true` when casing matters and you want to avoid accidental matches +- Use `UpsertDocument` when you are replacing the whole document on purpose; use preview/apply when you want a narrow, literal patch + ## Key Conventions Keys use **colon** as the separator. Slash-separated keys (`sprint/foo`) still work for backward compatibility, but colons are the canonical, recommended convention — they signal "DevBrain key" at a glance and avoid being confused with file paths. diff --git a/src/DevBrain.Functions/Models/ConditionalWriteResult.cs b/src/DevBrain.Functions/Models/ConditionalWriteResult.cs new file mode 100644 index 0000000..b75522c --- /dev/null +++ b/src/DevBrain.Functions/Models/ConditionalWriteResult.cs @@ -0,0 +1,7 @@ +namespace DevBrain.Functions.Models; + +public sealed record ConditionalWriteResult( + bool Applied, + string? CurrentContentHash, + BrainDocument? Document, + string Message); diff --git a/src/DevBrain.Functions/Models/EditApplyResult.cs b/src/DevBrain.Functions/Models/EditApplyResult.cs new file mode 100644 index 0000000..ccfde63 --- /dev/null +++ b/src/DevBrain.Functions/Models/EditApplyResult.cs @@ -0,0 +1,26 @@ +namespace DevBrain.Functions.Models; + +public sealed class EditApplyResult +{ + public string Key { get; set; } = string.Empty; + + public string Project { get; set; } = "default"; + + public bool Applied { get; set; } + + public int MatchCount { get; set; } + + public int ReplacedCount { get; set; } + + public string? PreviousContentHash { get; set; } + + public string? NewContentHash { get; set; } + + public int? NewContentLength { get; set; } + + public DateTimeOffset? UpdatedAt { get; set; } + + public string? UpdatedBy { get; set; } + + public string Message { get; set; } = string.Empty; +} diff --git a/src/DevBrain.Functions/Models/EditPreviewResult.cs b/src/DevBrain.Functions/Models/EditPreviewResult.cs new file mode 100644 index 0000000..05058d3 --- /dev/null +++ b/src/DevBrain.Functions/Models/EditPreviewResult.cs @@ -0,0 +1,30 @@ +namespace DevBrain.Functions.Models; + +public sealed class EditPreviewResult +{ + public string Key { get; set; } = string.Empty; + + public string Project { get; set; } = "default"; + + public bool Found { get; set; } + + public int MatchCount { get; set; } + + public int ExpectedOccurrences { get; set; } + + public bool Ambiguous { get; set; } + + public bool WouldReplace { get; set; } + + public string? CurrentContentHash { get; set; } + + public int? CurrentContentLength { get; set; } + + public int ReplacementDelta { get; set; } + + public string? PreviewBefore { get; set; } + + public string? PreviewAfter { get; set; } + + public string Message { get; set; } = string.Empty; +} diff --git a/src/DevBrain.Functions/Program.cs b/src/DevBrain.Functions/Program.cs index ab26b08..623a15b 100644 --- a/src/DevBrain.Functions/Program.cs +++ b/src/DevBrain.Functions/Program.cs @@ -72,6 +72,7 @@ }); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // ---------------- OAuth DCR facade (v1.6) ---------------- diff --git a/src/DevBrain.Functions/Services/ContentHashing.cs b/src/DevBrain.Functions/Services/ContentHashing.cs new file mode 100644 index 0000000..ded3b0f --- /dev/null +++ b/src/DevBrain.Functions/Services/ContentHashing.cs @@ -0,0 +1,22 @@ +using System.Security.Cryptography; +using System.Text; + +namespace DevBrain.Functions.Services; + +internal static class ContentHashing +{ + public static string ComputeSha256(string content) + { + var normalized = NormalizeForHash(content); + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(normalized)); + return Convert.ToHexStringLower(bytes); + } + + /// + /// Normalizes content before hashing so that trivial formatting differences + /// (line-ending style, trailing whitespace) don't produce different hashes + /// for semantically identical documents. The stored content is never modified. + /// + public static string NormalizeForHash(string content) => + content.ReplaceLineEndings("\n").TrimEnd(); +} diff --git a/src/DevBrain.Functions/Services/CosmosDocumentStore.cs b/src/DevBrain.Functions/Services/CosmosDocumentStore.cs index ccfd79a..73f6187 100644 --- a/src/DevBrain.Functions/Services/CosmosDocumentStore.cs +++ b/src/DevBrain.Functions/Services/CosmosDocumentStore.cs @@ -1,6 +1,4 @@ using System.Net; -using System.Security.Cryptography; -using System.Text; using DevBrain.Functions.Models; using Microsoft.Azure.Cosmos; using Microsoft.Extensions.Configuration; @@ -35,7 +33,7 @@ public CosmosDocumentStore(CosmosClient cosmosClient, IConfiguration configurati public async Task UpsertAsync(BrainDocument document) { document.Id = EncodeId(document.Key); - document.ContentHash = ComputeSha256(document.Content); + document.ContentHash = ContentHashing.ComputeSha256(document.Content); document.ContentLength = document.Content.Length; var response = await _container.UpsertItemAsync( document, @@ -45,20 +43,88 @@ public async Task UpsertAsync(BrainDocument document) private static string EncodeId(string key) => key.Replace('/', ':'); - private static string ComputeSha256(string content) + public async Task ReplaceIfHashMatchesAsync(BrainDocument document, string expectedContentHash) { - var normalized = NormalizeForHash(content); - var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(normalized)); - return Convert.ToHexStringLower(bytes); - } + var queryDefinition = new QueryDefinition( + "SELECT * FROM c WHERE c.key = @key AND c.project = @project OFFSET 0 LIMIT 1") + .WithParameter("@key", document.Key) + .WithParameter("@project", document.Project); - /// - /// Normalizes content before hashing so that trivial formatting differences - /// (line-ending style, trailing whitespace) don't produce different hashes - /// for semantically identical documents. The stored content is never modified. - /// - private static string NormalizeForHash(string content) => - content.ReplaceLineEndings("\n").TrimEnd(); + using var iterator = _container.GetItemQueryIterator(queryDefinition); + if (!iterator.HasMoreResults) + { + return new ConditionalWriteResult( + Applied: false, + CurrentContentHash: null, + Document: null, + Message: $"Document not found: '{document.Key}'"); + } + + var response = await iterator.ReadNextAsync(); + var existing = response.FirstOrDefault(); + if (existing is null) + { + return new ConditionalWriteResult( + Applied: false, + CurrentContentHash: null, + Document: null, + Message: $"Document not found: '{document.Key}'"); + } + + ItemResponse currentResponse; + try + { + currentResponse = await _container.ReadItemAsync( + existing.Id, + new PartitionKey(existing.Key)); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return new ConditionalWriteResult( + Applied: false, + CurrentContentHash: null, + Document: null, + Message: $"Document not found: '{document.Key}'"); + } + + existing = currentResponse.Resource; + var currentHash = existing.ContentHash ?? ContentHashing.ComputeSha256(existing.Content); + if (!string.Equals(currentHash, expectedContentHash, StringComparison.OrdinalIgnoreCase)) + { + return new ConditionalWriteResult( + Applied: false, + CurrentContentHash: currentHash, + Document: existing, + Message: "Document changed since preview. Re-run PreviewEditDocument and try again."); + } + + document.Id = existing.Id; + document.ContentHash = ContentHashing.ComputeSha256(document.Content); + document.ContentLength = document.Content.Length; + + try + { + var replaceResponse = await _container.ReplaceItemAsync( + document, + existing.Id, + new PartitionKey(existing.Key), + new ItemRequestOptions { IfMatchEtag = currentResponse.ETag }); + + return new ConditionalWriteResult( + Applied: true, + CurrentContentHash: currentHash, + Document: replaceResponse.Resource, + Message: "Write applied."); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed) + { + return new ConditionalWriteResult( + Applied: false, + CurrentContentHash: currentHash, + Document: null, + Message: "Document changed during apply. Re-run PreviewEditDocument and try again."); + } + } public async Task GetAsync(string key, string project) { @@ -316,7 +382,7 @@ public async Task AppendAsync( Tags = UnionTags(existing.Tags, tags), UpdatedAt = DateTimeOffset.UtcNow, UpdatedBy = updatedBy, - ContentHash = ComputeSha256(mergedContent), + ContentHash = ContentHashing.ComputeSha256(mergedContent), ContentLength = mergedContent.Length }; diff --git a/src/DevBrain.Functions/Services/DocumentEditService.cs b/src/DevBrain.Functions/Services/DocumentEditService.cs new file mode 100644 index 0000000..2f35321 --- /dev/null +++ b/src/DevBrain.Functions/Services/DocumentEditService.cs @@ -0,0 +1,253 @@ +using System.Text; +using DevBrain.Functions.Models; + +namespace DevBrain.Functions.Services; + +public sealed class DocumentEditService : IDocumentEditService +{ + private const int PreviewContextChars = 120; + + private readonly IDocumentStore _store; + + public DocumentEditService(IDocumentStore store) + { + _store = store; + } + + public async Task PreviewAsync( + string key, + string project, + string oldText, + string newText, + int expectedOccurrences, + bool caseSensitive) + { + var document = await _store.GetAsync(key, project); + if (document is null) + { + return new EditPreviewResult + { + Key = key, + Project = project, + Found = false, + ExpectedOccurrences = expectedOccurrences, + WouldReplace = false, + Message = $"Document not found: '{key}'" + }; + } + + var currentHash = document.ContentHash ?? ContentHashing.ComputeSha256(document.Content); + var matchIndexes = FindMatchIndexes(document.Content, oldText, caseSensitive); + var matchCount = matchIndexes.Count; + + if (matchCount == 0) + { + return new EditPreviewResult + { + Key = key, + Project = project, + Found = true, + MatchCount = 0, + ExpectedOccurrences = expectedOccurrences, + CurrentContentHash = currentHash, + CurrentContentLength = document.Content.Length, + WouldReplace = false, + Message = $"No matches found for the provided text in '{key}'." + }; + } + + if (matchCount != expectedOccurrences) + { + return new EditPreviewResult + { + Key = key, + Project = project, + Found = true, + MatchCount = matchCount, + ExpectedOccurrences = expectedOccurrences, + Ambiguous = matchCount > 1, + CurrentContentHash = currentHash, + CurrentContentLength = document.Content.Length, + WouldReplace = false, + Message = $"Expected {expectedOccurrences} match(es), found {matchCount}. Refusing ambiguous edit." + }; + } + + var replacedContent = ReplaceMatches(document.Content, oldText, newText, matchIndexes); + + return new EditPreviewResult + { + Key = key, + Project = project, + Found = true, + MatchCount = matchCount, + ExpectedOccurrences = expectedOccurrences, + Ambiguous = false, + WouldReplace = true, + CurrentContentHash = currentHash, + CurrentContentLength = document.Content.Length, + ReplacementDelta = replacedContent.Length - document.Content.Length, + PreviewBefore = BuildPreview(document.Content, matchIndexes[0], oldText.Length), + PreviewAfter = BuildPreview(replacedContent, matchIndexes[0], newText.Length), + Message = "Edit preview ready." + }; + } + + public async Task ApplyAsync( + string key, + string project, + string oldText, + string newText, + int expectedOccurrences, + bool caseSensitive, + string expectedContentHash, + string updatedBy) + { + var document = await _store.GetAsync(key, project); + if (document is null) + { + return new EditApplyResult + { + Key = key, + Project = project, + Applied = false, + Message = $"Document not found: '{key}'" + }; + } + + var currentHash = document.ContentHash ?? ContentHashing.ComputeSha256(document.Content); + if (!string.Equals(currentHash, expectedContentHash, StringComparison.OrdinalIgnoreCase)) + { + return new EditApplyResult + { + Key = key, + Project = project, + Applied = false, + PreviousContentHash = currentHash, + Message = "Document changed since preview. Re-run PreviewEditDocument and try again." + }; + } + + var matchIndexes = FindMatchIndexes(document.Content, oldText, caseSensitive); + var matchCount = matchIndexes.Count; + if (matchCount != expectedOccurrences) + { + return new EditApplyResult + { + Key = key, + Project = project, + Applied = false, + MatchCount = matchCount, + PreviousContentHash = currentHash, + Message = matchCount == 0 + ? $"No matches found for the provided text in '{key}'." + : $"Expected {expectedOccurrences} match(es), found {matchCount}. Refusing ambiguous edit." + }; + } + + var replacedContent = ReplaceMatches(document.Content, oldText, newText, matchIndexes); + var updated = new BrainDocument + { + Id = document.Id, + Key = document.Key, + Project = document.Project, + Content = replacedContent, + Tags = document.Tags, + UpdatedAt = DateTimeOffset.UtcNow, + UpdatedBy = updatedBy, + Ttl = document.Ttl + }; + + var writeResult = await _store.ReplaceIfHashMatchesAsync(updated, expectedContentHash); + if (!writeResult.Applied) + { + return new EditApplyResult + { + Key = key, + Project = project, + Applied = false, + MatchCount = matchCount, + ReplacedCount = 0, + PreviousContentHash = writeResult.CurrentContentHash ?? currentHash, + Message = writeResult.Message + }; + } + + var saved = writeResult.Document!; + return new EditApplyResult + { + Key = saved.Key, + Project = saved.Project, + Applied = true, + MatchCount = matchCount, + ReplacedCount = matchCount, + PreviousContentHash = currentHash, + NewContentHash = saved.ContentHash, + NewContentLength = saved.ContentLength ?? saved.Content.Length, + UpdatedAt = saved.UpdatedAt, + UpdatedBy = saved.UpdatedBy, + Message = "Edit applied." + }; + } + + private static List FindMatchIndexes(string content, string oldText, bool caseSensitive) + { + var comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + var indexes = new List(); + var startIndex = 0; + + while (startIndex <= content.Length - oldText.Length) + { + var matchIndex = content.IndexOf(oldText, startIndex, comparison); + if (matchIndex < 0) + { + break; + } + + indexes.Add(matchIndex); + startIndex = matchIndex + oldText.Length; + } + + return indexes; + } + + private static string ReplaceMatches(string content, string oldText, string newText, IReadOnlyList matchIndexes) + { + if (matchIndexes.Count == 0) + { + return content; + } + + var builder = new StringBuilder(content.Length + (newText.Length - oldText.Length) * matchIndexes.Count); + var cursor = 0; + + foreach (var matchIndex in matchIndexes) + { + builder.Append(content, cursor, matchIndex - cursor); + builder.Append(newText); + cursor = matchIndex + oldText.Length; + } + + builder.Append(content, cursor, content.Length - cursor); + return builder.ToString(); + } + + private static string BuildPreview(string content, int matchIndex, int matchLength) + { + var previewStart = Math.Max(0, matchIndex - PreviewContextChars); + var previewEnd = Math.Min(content.Length, matchIndex + matchLength + PreviewContextChars); + var preview = content[previewStart..previewEnd]; + + if (previewStart > 0) + { + preview = "..." + preview; + } + + if (previewEnd < content.Length) + { + preview += "..."; + } + + return preview; + } +} diff --git a/src/DevBrain.Functions/Services/IDocumentEditService.cs b/src/DevBrain.Functions/Services/IDocumentEditService.cs new file mode 100644 index 0000000..3621736 --- /dev/null +++ b/src/DevBrain.Functions/Services/IDocumentEditService.cs @@ -0,0 +1,24 @@ +using DevBrain.Functions.Models; + +namespace DevBrain.Functions.Services; + +public interface IDocumentEditService +{ + Task PreviewAsync( + string key, + string project, + string oldText, + string newText, + int expectedOccurrences, + bool caseSensitive); + + Task ApplyAsync( + string key, + string project, + string oldText, + string newText, + int expectedOccurrences, + bool caseSensitive, + string expectedContentHash, + string updatedBy); +} diff --git a/src/DevBrain.Functions/Services/IDocumentStore.cs b/src/DevBrain.Functions/Services/IDocumentStore.cs index d769852..7f95d7d 100644 --- a/src/DevBrain.Functions/Services/IDocumentStore.cs +++ b/src/DevBrain.Functions/Services/IDocumentStore.cs @@ -5,6 +5,7 @@ namespace DevBrain.Functions.Services; public interface IDocumentStore { Task UpsertAsync(BrainDocument document); + Task ReplaceIfHashMatchesAsync(BrainDocument document, string expectedContentHash); Task GetAsync(string key, string project); Task> ListAsync(string project, string? prefix = null); Task> SearchAsync(string query, string project); diff --git a/src/DevBrain.Functions/Tools/DocumentTools.cs b/src/DevBrain.Functions/Tools/DocumentTools.cs index ac1b03b..3d61c54 100644 --- a/src/DevBrain.Functions/Tools/DocumentTools.cs +++ b/src/DevBrain.Functions/Tools/DocumentTools.cs @@ -1,5 +1,3 @@ -using System.Security.Cryptography; -using System.Text; using System.Text.Json; using DevBrain.Functions.Models; using DevBrain.Functions.Services; @@ -11,10 +9,12 @@ namespace DevBrain.Functions.Tools; public sealed class DocumentTools { private readonly IDocumentStore _store; + private readonly IDocumentEditService _editService; - public DocumentTools(IDocumentStore store) + public DocumentTools(IDocumentStore store, IDocumentEditService editService) { _store = store; + _editService = editService; } [Function(nameof(UpsertDocument))] @@ -130,7 +130,7 @@ public async Task CompareDocument( return "Provide either 'content' or 'contentHash', not both."; } - var candidateHash = contentHash ?? ComputeSha256(content!); + var candidateHash = contentHash ?? ContentHashing.ComputeSha256(content!); var document = await _store.GetMetadataAsync(key, project ?? "default"); if (document is null) @@ -159,6 +159,101 @@ public async Task CompareDocument( }); } + [Function(nameof(PreviewEditDocument))] + public async Task PreviewEditDocument( + [McpToolTrigger("PreviewEditDocument", "Preview an exact text edit without writing. Matches literal text only. Returns match count, preview snippets, and the current content hash to pass into ApplyEditDocument.")] + ToolInvocationContext context, + [McpToolProperty("key", "Document key to edit.", isRequired: true)] + string key, + [McpToolProperty("oldText", "Exact literal text to find in the document.", isRequired: true)] + string oldText, + [McpToolProperty("newText", "Replacement text to substitute for oldText. May be empty to delete the match.", isRequired: true)] + string newText, + [McpToolProperty("expectedOccurrences", "Expected number of literal matches. Defaults to 1; preview refuses ambiguous edits when the actual count differs.")] + int? expectedOccurrences, + [McpToolProperty("caseSensitive", "When true, matching is case-sensitive. Defaults to false.")] + bool? caseSensitive, + [McpToolProperty("project", "Project scope (default: \"default\").")] + string? project) + { + if (string.IsNullOrEmpty(oldText)) + { + return JsonSerializer.Serialize(new EditPreviewResult + { + Key = key, + Project = project ?? "default", + Found = false, + WouldReplace = false, + Message = "'oldText' must be a non-empty string." + }); + } + + var result = await _editService.PreviewAsync( + key, + project ?? "default", + oldText, + newText, + expectedOccurrences ?? 1, + caseSensitive ?? false); + + return JsonSerializer.Serialize(result); + } + + [Function(nameof(ApplyEditDocument))] + public async Task ApplyEditDocument( + [McpToolTrigger("ApplyEditDocument", "Apply an exact text edit after preview. Fails if the document changed since preview, if the match count differs, or if the edit is ambiguous. Matches literal text only.")] + ToolInvocationContext context, + [McpToolProperty("key", "Document key to edit.", isRequired: true)] + string key, + [McpToolProperty("oldText", "Exact literal text to find in the document.", isRequired: true)] + string oldText, + [McpToolProperty("newText", "Replacement text to substitute for oldText. May be empty to delete the match.", isRequired: true)] + string newText, + [McpToolProperty("expectedContentHash", "Content hash returned by PreviewEditDocument. Apply fails if the stored document no longer matches this hash.", isRequired: true)] + string expectedContentHash, + [McpToolProperty("expectedOccurrences", "Expected number of literal matches. Defaults to 1; apply refuses ambiguous edits when the actual count differs.")] + int? expectedOccurrences, + [McpToolProperty("caseSensitive", "When true, matching is case-sensitive. Defaults to false.")] + bool? caseSensitive, + [McpToolProperty("project", "Project scope (default: \"default\").")] + string? project, + FunctionContext functionContext) + { + if (string.IsNullOrEmpty(oldText)) + { + return JsonSerializer.Serialize(new EditApplyResult + { + Key = key, + Project = project ?? "default", + Applied = false, + Message = "'oldText' must be a non-empty string." + }); + } + + if (string.IsNullOrWhiteSpace(expectedContentHash)) + { + return JsonSerializer.Serialize(new EditApplyResult + { + Key = key, + Project = project ?? "default", + Applied = false, + Message = "'expectedContentHash' is required." + }); + } + + var result = await _editService.ApplyAsync( + key, + project ?? "default", + oldText, + newText, + expectedOccurrences ?? 1, + caseSensitive ?? false, + expectedContentHash, + GetCallerIdentity(functionContext)); + + return JsonSerializer.Serialize(result); + } + [Function(nameof(ListDocuments))] public async Task ListDocuments( [McpToolTrigger("ListDocuments", "List stored document keys, optionally filtered by prefix. If the project has no matching documents and a similarly-named project exists, a single suggestion entry (key \"_suggestion\") is returned instead.")] @@ -407,13 +502,6 @@ public async Task SearchDocuments( return $"Keys must use ':' as separator. Got '{key}' — did you mean '{suggested}'?"; } - private static string ComputeSha256(string content) - { - var normalized = content.ReplaceLineEndings("\n").TrimEnd(); - var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(normalized)); - return Convert.ToHexStringLower(bytes); - } - private static string GetCallerIdentity(FunctionContext functionContext) { var features = functionContext.Features; diff --git a/tests/DevBrain.Functions.Tests/Services/DocumentEditServiceTests.cs b/tests/DevBrain.Functions.Tests/Services/DocumentEditServiceTests.cs new file mode 100644 index 0000000..fbc8b8f --- /dev/null +++ b/tests/DevBrain.Functions.Tests/Services/DocumentEditServiceTests.cs @@ -0,0 +1,280 @@ +using DevBrain.Functions.Models; +using DevBrain.Functions.Services; + +namespace DevBrain.Functions.Tests.Services; + +public sealed class DocumentEditServiceTests +{ + [Fact] + public async Task PreviewAsync_SingleExactMatch_ReturnsPreviewAndHash() + { + var store = new FakeDocumentStore(new BrainDocument + { + Id = "state:current", + Key = "state:current", + Project = "devbrain", + Content = "Status: draft\nOwner: Derek", + Tags = ["state"], + UpdatedAt = DateTimeOffset.UtcNow, + UpdatedBy = "seed" + }); + var service = new DocumentEditService(store); + + var result = await service.PreviewAsync( + "state:current", + "devbrain", + "Status: draft", + "Status: in progress", + expectedOccurrences: 1, + caseSensitive: true); + + Assert.True(result.Found); + Assert.True(result.WouldReplace); + Assert.False(result.Ambiguous); + Assert.Equal(1, result.MatchCount); + Assert.Equal(ContentHashing.ComputeSha256("Status: draft\nOwner: Derek"), result.CurrentContentHash); + Assert.Contains("Status: draft", result.PreviewBefore); + Assert.Contains("Status: in progress", result.PreviewAfter); + } + + [Fact] + public async Task PreviewAsync_MultipleMatchesWithSingleExpectation_RefusesAmbiguousEdit() + { + var store = new FakeDocumentStore(new BrainDocument + { + Id = "state:current", + Key = "state:current", + Project = "devbrain", + Content = "draft\ndraft\nfinal", + UpdatedAt = DateTimeOffset.UtcNow, + UpdatedBy = "seed" + }); + var service = new DocumentEditService(store); + + var result = await service.PreviewAsync( + "state:current", + "devbrain", + "draft", + "published", + expectedOccurrences: 1, + caseSensitive: true); + + Assert.True(result.Found); + Assert.False(result.WouldReplace); + Assert.True(result.Ambiguous); + Assert.Equal(2, result.MatchCount); + } + + [Fact] + public async Task ApplyAsync_WithMatchingHash_ReplacesContentAndPreservesTags() + { + var originalContent = "Status: draft\nOwner: Derek"; + var store = new FakeDocumentStore(new BrainDocument + { + Id = "state:current", + Key = "state:current", + Project = "devbrain", + Content = originalContent, + Tags = ["state", "current"], + UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), + UpdatedBy = "seed" + }); + var service = new DocumentEditService(store); + + var result = await service.ApplyAsync( + "state:current", + "devbrain", + "Status: draft", + "Status: in progress", + expectedOccurrences: 1, + caseSensitive: true, + expectedContentHash: ContentHashing.ComputeSha256(originalContent), + updatedBy: "agent@example.com"); + + Assert.True(result.Applied); + Assert.Equal(1, result.ReplacedCount); + Assert.Equal(ContentHashing.ComputeSha256(originalContent), result.PreviousContentHash); + Assert.Equal(ContentHashing.ComputeSha256("Status: in progress\nOwner: Derek"), result.NewContentHash); + + var saved = await store.GetAsync("state:current", "devbrain"); + Assert.NotNull(saved); + Assert.Equal("Status: in progress\nOwner: Derek", saved.Content); + Assert.Equal(["state", "current"], saved.Tags); + Assert.Equal("agent@example.com", saved.UpdatedBy); + } + + [Fact] + public async Task ApplyAsync_WithStaleHash_RefusesWrite() + { + var store = new FakeDocumentStore(new BrainDocument + { + Id = "state:current", + Key = "state:current", + Project = "devbrain", + Content = "Status: draft", + UpdatedAt = DateTimeOffset.UtcNow, + UpdatedBy = "seed" + }); + var service = new DocumentEditService(store); + + var result = await service.ApplyAsync( + "state:current", + "devbrain", + "Status: draft", + "Status: in progress", + expectedOccurrences: 1, + caseSensitive: true, + expectedContentHash: ContentHashing.ComputeSha256("Status: stale"), + updatedBy: "agent@example.com"); + + Assert.False(result.Applied); + Assert.Equal(ContentHashing.ComputeSha256("Status: draft"), result.PreviousContentHash); + + var saved = await store.GetAsync("state:current", "devbrain"); + Assert.NotNull(saved); + Assert.Equal("Status: draft", saved.Content); + } + + [Fact] + public async Task ApplyAsync_ReplacementContainingOldText_ReplacesOnlyExpectedMatches() + { + var store = new FakeDocumentStore(new BrainDocument + { + Id = "state:current", + Key = "state:current", + Project = "devbrain", + Content = "draft once", + UpdatedAt = DateTimeOffset.UtcNow, + UpdatedBy = "seed" + }); + var service = new DocumentEditService(store); + + var result = await service.ApplyAsync( + "state:current", + "devbrain", + "draft", + "draft v2", + expectedOccurrences: 1, + caseSensitive: true, + expectedContentHash: ContentHashing.ComputeSha256("draft once"), + updatedBy: "agent@example.com"); + + Assert.True(result.Applied); + + var saved = await store.GetAsync("state:current", "devbrain"); + Assert.NotNull(saved); + Assert.Equal("draft v2 once", saved.Content); + } + + private sealed class FakeDocumentStore : IDocumentStore + { + private readonly Dictionary<(string Key, string Project), BrainDocument> _documents = new(); + + public FakeDocumentStore(params BrainDocument[] documents) + { + foreach (var document in documents) + { + document.ContentHash ??= ContentHashing.ComputeSha256(document.Content); + document.ContentLength ??= document.Content.Length; + _documents[(document.Key, document.Project)] = Clone(document); + } + } + + public Task UpsertAsync(BrainDocument document) + { + document.Id = string.IsNullOrEmpty(document.Id) ? document.Key.Replace('/', ':') : document.Id; + document.ContentHash = ContentHashing.ComputeSha256(document.Content); + document.ContentLength = document.Content.Length; + _documents[(document.Key, document.Project)] = Clone(document); + return Task.FromResult(Clone(document)); + } + + public Task ReplaceIfHashMatchesAsync(BrainDocument document, string expectedContentHash) + { + if (!_documents.TryGetValue((document.Key, document.Project), out var existing)) + { + return Task.FromResult(new ConditionalWriteResult(false, null, null, $"Document not found: '{document.Key}'")); + } + + var currentHash = existing.ContentHash ?? ContentHashing.ComputeSha256(existing.Content); + if (!string.Equals(currentHash, expectedContentHash, StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new ConditionalWriteResult( + false, + currentHash, + Clone(existing), + "Document changed since preview. Re-run PreviewEditDocument and try again.")); + } + + document.Id = existing.Id; + document.ContentHash = ContentHashing.ComputeSha256(document.Content); + document.ContentLength = document.Content.Length; + _documents[(document.Key, document.Project)] = Clone(document); + + return Task.FromResult(new ConditionalWriteResult( + true, + currentHash, + Clone(document), + "Write applied.")); + } + + public Task GetAsync(string key, string project) + { + _documents.TryGetValue((key, project), out var document); + return Task.FromResult(document is null ? null : Clone(document)); + } + + public Task> ListAsync(string project, string? prefix = null) => + Task.FromResult>([]); + + public Task> SearchAsync(string query, string project) => + Task.FromResult>([]); + + public Task GetMetadataAsync(string key, string project) + { + _documents.TryGetValue((key, project), out var document); + if (document is null) + { + return Task.FromResult(null); + } + + return Task.FromResult(new BrainDocument + { + Id = document.Id, + Key = document.Key, + Project = document.Project, + Tags = [.. document.Tags], + UpdatedAt = document.UpdatedAt, + UpdatedBy = document.UpdatedBy, + ContentHash = document.ContentHash, + ContentLength = document.ContentLength + }); + } + + public Task TouchAllAsync() => Task.FromResult(_documents.Count); + + public Task DeleteAsync(string key, string project) => + Task.FromResult(_documents.Remove((key, project))); + + public Task AppendAsync(string key, string project, string content, string separator, string[] tags, string updatedBy) => + throw new NotSupportedException(); + + public Task UpsertChunkAsync(string key, string project, string content, int chunkIndex, int totalChunks, string[] tags, string updatedBy) => + throw new NotSupportedException(); + + private static BrainDocument Clone(BrainDocument document) => + new() + { + Id = document.Id, + Key = document.Key, + Project = document.Project, + Content = document.Content, + Tags = [.. document.Tags], + UpdatedAt = document.UpdatedAt, + ContentHash = document.ContentHash, + ContentLength = document.ContentLength, + UpdatedBy = document.UpdatedBy, + Ttl = document.Ttl + }; + } +}