const std = @import("std"); const types = @import("../shared/types.zig"); const Allocator = std.mem.Allocator; /// Content digest recorded for a file read by the current session. pub const ContentHash = types.ContentHash; /// Snapshot of a file at the moment an agent-visible read succeeded. /// Tracks model-visible coverage separately from full-file freshness coverage so /// tools can enforce the stricter invariant only where whole-file model context matters. pub const Record = struct { mtime_ns: i128, content_hash: ContentHash, /// True iff the model-visible read returned the entire file content /// (no display cap hit, no start_line/line_count narrowing). model_view_covers_full_file: bool, /// True iff content_hash was computed over the entire file on disk /// at read time (independent of what the model saw). snapshot_covers_full_file: bool, }; /// Session-scoped in-memory index of files read by the agent. pub const ReadTracker = struct { alloc: Allocator, entries: std.StringHashMap(Record), /// Initializes an empty tracker. Call deinit to free owned path keys. pub fn init(alloc: Allocator) ReadTracker { return .{ .alloc = alloc, .entries = std.StringHashMap(Record).init(alloc), }; } /// Records a snapshot for path, replacing any previous record. pub fn record(self: *ReadTracker, path: []const u8, value: Record) Allocator.Error!void { const owned_path = try self.alloc.dupe(u8, path); errdefer self.alloc.free(owned_path); const entry = try self.entries.getOrPut(owned_path); if (entry.found_existing) { self.alloc.free(owned_path); } entry.value_ptr.* = value; } /// Returns the snapshot for path, if the session has one. pub fn lookup(self: *const ReadTracker, path: []const u8) ?Record { return self.entries.get(path); } /// Frees all owned path keys and map storage. pub fn deinit(self: *ReadTracker) void { var keys = self.entries.keyIterator(); while (keys.next()) |key| self.alloc.free(key.*); self.entries.deinit(); self.* = undefined; } }; /// Computes the content hash used by read-before-overwrite checks. pub fn contentHash(bytes: []const u8) ContentHash { var hasher = std.crypto.hash.sha2.Sha256.init(.{}); hasher.update(bytes); return hasher.finalResult(); } test "ReadTracker records, looks up, overwrites, and frees owned keys" { const alloc = std.testing.allocator; var tracker = ReadTracker.init(alloc); defer tracker.deinit(); const first = Record{ .mtime_ns = 10, .content_hash = contentHash("first"), .model_view_covers_full_file = true, .snapshot_covers_full_file = true, }; try tracker.record("/tmp/file.txt", first); const stored_first = tracker.lookup("/tmp/file.txt").?; try std.testing.expectEqual(@as(i128, 10), stored_first.mtime_ns); try std.testing.expectEqualSlices(u8, &first.content_hash, &stored_first.content_hash); try std.testing.expect(stored_first.model_view_covers_full_file); try std.testing.expect(stored_first.snapshot_covers_full_file); const second = Record{ .mtime_ns = 20, .content_hash = contentHash("second"), .model_view_covers_full_file = false, .snapshot_covers_full_file = false, }; try tracker.record("/tmp/file.txt", second); const stored_second = tracker.lookup("/tmp/file.txt").?; try std.testing.expectEqual(@as(i128, 20), stored_second.mtime_ns); try std.testing.expectEqualSlices(u8, &second.content_hash, &stored_second.content_hash); try std.testing.expect(!stored_second.model_view_covers_full_file); try std.testing.expect(!stored_second.snapshot_covers_full_file); } test "ReadTracker lookup returns null for unread paths" { var tracker = ReadTracker.init(std.testing.allocator); defer tracker.deinit(); try std.testing.expect(tracker.lookup("/tmp/missing.txt") == null); } test "contentHash distinguishes empty and non-empty content" { const empty = contentHash(""); const filled = contentHash("x"); try std.testing.expect(!std.mem.eql(u8, &empty, &filled)); try std.testing.expectEqualSlices(u8, &empty, &contentHash("")); }