using System.Text.Json; using ClawdDotNet.Core.Tools; using ClawdDotNet.Tools.FileRW; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Tools.Tests.FileRW; /// /// Sorted, capped listing (#673). /// /// Occasion from test operation: the operations check reported two hourly files as /// missing that existed. The directory delivered 105 entries in the order of the /// file system — finding two names in that is search work, and a cheap model /// does it wrong. /// public sealed class DirectoryListTests : IDisposable { private readonly string _basis; private readonly string _personal; private readonly string _shared; private readonly FileRWTool _tool = new(); public DirectoryListTests() { _basis = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); _personal = Path.Combine(_basis, "Workspace"); _shared = Path.Combine(_basis, "SharedWorkspace"); Directory.CreateDirectory(_personal); Directory.CreateDirectory(Path.Combine(_shared, "wetter", "stunden")); } public void Dispose() { try { Directory.Delete(_basis, recursive: true); } catch { /* Cleaning up is secondary */ } } private AgentToolContext Context() => new( "hausmeister", "inst", new Dictionary { ["sharedAccessLevel"] = "ReadWrite" }, new NullStateStore(), NullLogger.Instance, CancellationToken.None, WorkspacePath: _personal, SharedWorkspacePath: _shared); private Task Run(string json) => _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None); /// Hourly files, deliberately written in mixed order. private void HourlyFiles(int count, string tag = "2026-09-21") { var verzeichnis = Path.Combine(_shared, "wetter", "stunden"); foreach (var hour in Enumerable.Range(0, count).OrderBy(_ => Guid.NewGuid())) { var path = Path.Combine(verzeichnis, $"berlin_{tag}_{hour:00}-00.txt"); File.WriteAllText(path, $"{tag} {hour:00}:00 Temperatur {10 + hour} C"); File.SetLastWriteTime(path, DateTime.Parse($"{tag}T{hour:00}:05:00")); } } private static List Names(string content) => content .Split('\n', StringSplitOptions.RemoveEmptyEntries) .Select(z => z.Trim()) .Where(z => z.StartsWith("20", StringComparison.Ordinal)) .Select(z => z.Split(" ", StringSplitOptions.RemoveEmptyEntries).Last()) .ToList(); // ═══════════════════ Order ═══════════════════ [Fact] public async Task The_newest_file_is_on_top() { HourlyFiles(6); var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden"}"""); result.Success.ShouldBeTrue(result.ErrorMessage); result.Content.ShouldContain("newest first"); Names(result.Content).First().ShouldBe("berlin_2026-09-21_05-00.txt"); Names(result.Content).Last().ShouldBe("berlin_2026-09-21_00-00.txt"); } [Fact] public async Task Sorted_by_name_runs_ascending() { HourlyFiles(4); var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","sort":"name"}"""); Names(result.Content).ShouldBe([ "berlin_2026-09-21_00-00.txt", "berlin_2026-09-21_01-00.txt", "berlin_2026-09-21_02-00.txt", "berlin_2026-09-21_03-00.txt" ]); } [Fact] public async Task The_direction_can_be_reversed() { HourlyFiles(4); var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","sort":"modified","order":"asc"}"""); result.Content.ShouldContain("oldest first"); Names(result.Content).First().ShouldBe("berlin_2026-09-21_00-00.txt"); } // ═══════════════════ Cap and total count ═══════════════════ [Fact] public async Task The_header_always_names_the_total() { HourlyFiles(24); HourlyFiles(24, "2026-09-20"); HourlyFiles(24, "2026-09-19"); var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden"}"""); result.Content.ShouldContain($"{FileRWTool.DefaultEntries} of 72 entries"); result.Content.ShouldContain("22 more not shown"); result.Content.ShouldContain("'pattern'", Case.Sensitive, "der Agent soll wissen, wie er gezielt fragt"); Names(result.Content).Count.ShouldBe(FileRWTool.DefaultEntries); } [Fact] public async Task A_custom_limit_is_respected_and_capped() { HourlyFiles(10); var little = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","limit":3}"""); Names(little.Content).Count.ShouldBe(3); little.Content.ShouldContain("3 of 10 entries"); var tooMuch = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","limit":99999}"""); Names(tooMuch.Content).Count.ShouldBe(10, "mehr als da ist gibt es nicht"); } // ═══════════════════ Asking specifically ═══════════════════ [Fact] public async Task A_pattern_answers_the_question_for_a_specific_file() { HourlyFiles(24); HourlyFiles(24, "2026-09-20"); var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","pattern":"berlin_2026-09-21_1?-00.txt"}"""); result.Success.ShouldBeTrue(result.ErrorMessage); result.Content.ShouldContain("10 of 10 entries"); result.Content.ShouldContain("Pattern:"); Names(result.Content).ShouldAllBe(n => n.StartsWith("berlin_2026-09-21_1")); } [Fact] public async Task An_empty_directory_is_not_an_error_but_a_zero() { var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden"}"""); result.Success.ShouldBeTrue(result.ErrorMessage); result.Content.ShouldContain("0 of 0 entries"); } // ═══════════════════ Form ═══════════════════ [Fact] public async Task Every_entry_is_on_exactly_one_line() { HourlyFiles(3); Directory.CreateDirectory(Path.Combine(_shared, "wetter", "stunden", "alt")); var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden"}"""); result.Content.ShouldNotContain("\"Name\":", Case.Sensitive, "kein eingeruecktes JSON mehr"); result.Content.ShouldContain("alt/", Case.Sensitive); result.Content.ShouldContain("", Case.Sensitive); Names(result.Content).Count.ShouldBe(4); } // ═══════════════════ The pattern is not a second path ═══════════════════ [Theory] [InlineData("list")] [InlineData("read_many")] public async Task A_pattern_must_not_lead_out_of_the_directory(string action) { // Directory.GetFiles evaluates separators in the pattern: "../*" lists the // parent directory without the path check ever being asked. HourlyFiles(2); File.WriteAllText(Path.Combine(_shared, "wetter", "geheim.txt"), "nicht fuer den Agenten"); var result = await Run($$"""{"action":"{{action}}","workspace":"shared","path":"wetter/stunden","pattern":"../*"}"""); result.Success.ShouldBeFalse("ein Muster ist ein Dateiname, kein Pfad"); result.ErrorMessage.ShouldNotBeNull().ShouldContain("pattern", Case.Insensitive); (result.Content ?? "").ShouldNotContain("geheim"); } [Theory] [InlineData("unter/*.txt")] [InlineData("..\\*")] [InlineData("*/../*")] public async Task The_other_path_spellings_are_rejected_too(string pattern) { HourlyFiles(2); var input = JsonSerializer.SerializeToElement( new { action = "list", workspace = "shared", path = "wetter/stunden", pattern = pattern }); var result = await _tool.ExecuteAsync(input, Context(), CancellationToken.None); result.Success.ShouldBeFalse(); result.ErrorMessage.ShouldNotBeNull().ShouldContain("not a path"); } }