using System.Text.Json; using ClawdDotNet.Core.State; using ClawdDotNet.Core.Tools; using ClawdDotNet.Tools.FileRW; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Tools.Tests.FileRW; /// /// The follow-up check after a crash (#555) and atomic writing (#553). /// /// The file system is not abstracted here either — this is precisely about what is on /// disk after a call was interrupted. /// public sealed class FollowUpCheckTests : IDisposable { private readonly string _root; private readonly FileRWTool _tool = new(); public FollowUpCheckTests() { _root = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Workspace"); Directory.CreateDirectory(_root); } public void Dispose() { try { Directory.Delete(Path.GetDirectoryName(_root)!, recursive: true); } catch { /* Cleaning up is secondary */ } } private AgentToolContext Context() => new( "a1", "inst", new Dictionary(), new NullStateStore(), NullLogger.Instance, CancellationToken.None, WorkspacePath: _root); private Task Probe(string json) => _tool.ProbeAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None); private Task Run(string json) => _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None); // ═══════════════════ #553: writing atomically ═══════════════════ [Fact] public async Task Writing_leaves_no_side_files() { var result = await Run("""{"action":"write","path":"a.txt","content":"Inhalt"}"""); result.Success.ShouldBeTrue(); // Atomic writing goes via a side file — it must be gone afterwards. Directory.GetFiles(_root).ShouldHaveSingleItem(); (await File.ReadAllTextAsync(Path.Combine(_root, "a.txt"))).ShouldBe("Inhalt"); } [Fact] public async Task Overwriting_replaces_the_content_completely() { await Run("""{"action":"write","path":"a.txt","content":"ein sehr langer alter Inhalt"}"""); await Run("""{"action":"write","path":"a.txt","content":"kurz"}"""); // When replacing, no remnant of the old, longer version may remain. (await File.ReadAllTextAsync(Path.Combine(_root, "a.txt"))).ShouldBe("kurz"); } // ═══════════════════ #555: follow-up check for write ═══════════════════ [Fact] public async Task Missing_file_means_nothing_happened() { var finding = await Probe("""{"action":"write","path":"nie.txt","content":"x"}"""); finding.Outcome.ShouldBe(ProbeOutcome.NotStarted); } [Fact] public async Task The_intended_content_means_through() { await Run("""{"action":"write","path":"a.txt","content":"fertig"}"""); var finding = await Probe("""{"action":"write","path":"a.txt","content":"fertig"}"""); finding.Outcome.ShouldBe(ProbeOutcome.Complete); } [Fact] public async Task The_old_content_means_not_through() { await Run("""{"action":"write","path":"a.txt","content":"alt"}"""); // This is what it would look like if the crash came before the replacement. var finding = await Probe("""{"action":"write","path":"a.txt","content":"neu"}"""); finding.Outcome.ShouldBe(ProbeOutcome.NotStarted); } // ═══════════════════ #555: follow-up check for append ═══════════════════ [Fact] public async Task Fully_appended_text_is_recognised() { await Run("""{"action":"write","path":"log.txt","content":"Zeile 1\n"}"""); await Run("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}"""); var finding = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}"""); finding.Outcome.ShouldBe(ProbeOutcome.Complete); } [Fact] public async Task A_half_appended_text_is_recognised_as_a_tear() { // Appending is not atomic — exactly this state can really arise. // Here it is produced by hand, because a real tear cannot be reproduced. var path = Path.Combine(_root, "log.txt"); await File.WriteAllTextAsync(path, "Zeile 1\nZeile 2 ist nur halb ang"); var finding = await Probe( """{"action":"append","path":"log.txt","content":"Zeile 2 ist nur halb angekommen\n"}"""); finding.Outcome.ShouldBe(ProbeOutcome.Partial); finding.Evidence.ShouldContain("of"); } [Fact] public async Task If_the_text_is_missing_entirely_the_call_counts_as_not_happened() { await Run("""{"action":"write","path":"log.txt","content":"Zeile 1\n"}"""); var finding = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}"""); finding.Outcome.ShouldBe(ProbeOutcome.NotStarted); } // ═══════════════════ The limits of the follow-up check ═══════════════════ [Theory] [InlineData("read")] [InlineData("delete")] [InlineData("list")] public async Task Nothing_is_claimed_for_other_actions(string action) { var finding = await Probe($$"""{"action":"{{action}}","path":"a.txt"}"""); finding.Outcome.ShouldBe(ProbeOutcome.Unknown); } [Fact] public async Task An_unusable_path_gives_unknown_instead_of_an_exception() { // The follow-up check must never throw: a failure of the follow-up check is a statement // about the follow-up check, not about the world. var finding = await Probe("""{"action":"write","path":"../../ausserhalb.txt","content":"x"}"""); finding.Outcome.ShouldBe(ProbeOutcome.Unknown); } } internal sealed class NullStateStore : IStateStore { public Task GetAsync(string key, CancellationToken ct) => Task.FromResult(null); public Task SetAsync(string key, string value, CancellationToken ct) => Task.CompletedTask; public Task DeleteAsync(string key, CancellationToken ct) => Task.CompletedTask; }