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 NachschauTests : IDisposable { private readonly string _root; private readonly FileRWTool _tool = new(); public NachschauTests() { _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 Schreiben_hinterlaesst_keine_Nebendateien() { 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 Ueberschreiben_ersetzt_den_Inhalt_vollstaendig() { 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 Fehlende_Datei_heisst_nichts_geschehen() { var befund = await Probe("""{"action":"write","path":"nie.txt","content":"x"}"""); befund.Outcome.ShouldBe(ProbeOutcome.NotStarted); } [Fact] public async Task Der_beabsichtigte_Inhalt_heisst_durchgekommen() { await Run("""{"action":"write","path":"a.txt","content":"fertig"}"""); var befund = await Probe("""{"action":"write","path":"a.txt","content":"fertig"}"""); befund.Outcome.ShouldBe(ProbeOutcome.Complete); } [Fact] public async Task Der_alte_Inhalt_heisst_nicht_durchgekommen() { await Run("""{"action":"write","path":"a.txt","content":"alt"}"""); // This is what it would look like if the crash came before the replacement. var befund = await Probe("""{"action":"write","path":"a.txt","content":"neu"}"""); befund.Outcome.ShouldBe(ProbeOutcome.NotStarted); } // ═══════════════════ #555: follow-up check for append ═══════════════════ [Fact] public async Task Vollstaendig_angehaengter_Text_wird_erkannt() { await Run("""{"action":"write","path":"log.txt","content":"Zeile 1\n"}"""); await Run("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}"""); var befund = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}"""); befund.Outcome.ShouldBe(ProbeOutcome.Complete); } [Fact] public async Task Ein_halb_angehaengter_Text_wird_als_Riss_erkannt() { // Appending is not atomic — exactly this state can really arise. // Here it is produced by hand, because a real tear cannot be reproduced. var pfad = Path.Combine(_root, "log.txt"); await File.WriteAllTextAsync(pfad, "Zeile 1\nZeile 2 ist nur halb ang"); var befund = await Probe( """{"action":"append","path":"log.txt","content":"Zeile 2 ist nur halb angekommen\n"}"""); befund.Outcome.ShouldBe(ProbeOutcome.Partial); befund.Evidence.ShouldContain("of"); } [Fact] public async Task Fehlt_der_Text_ganz_gilt_der_Aufruf_als_nicht_geschehen() { await Run("""{"action":"write","path":"log.txt","content":"Zeile 1\n"}"""); var befund = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}"""); befund.Outcome.ShouldBe(ProbeOutcome.NotStarted); } // ═══════════════════ The limits of the follow-up check ═══════════════════ [Theory] [InlineData("read")] [InlineData("delete")] [InlineData("list")] public async Task Fuer_andere_Aktionen_wird_nichts_behauptet(string aktion) { var befund = await Probe($$"""{"action":"{{aktion}}","path":"a.txt"}"""); befund.Outcome.ShouldBe(ProbeOutcome.Unknown); } [Fact] public async Task Ein_unbrauchbarer_Pfad_ergibt_Unbekannt_statt_einer_Ausnahme() { // 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 befund = await Probe("""{"action":"write","path":"../../ausserhalb.txt","content":"x"}"""); befund.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; }