ReviewOS

also looking at this

zig-utils/zig-test-framework

Make watch mode rerun affected tests

#28
Merged glennmichael123 wants to merge codex/issue-11-watch-affected into main
5 files +621 -105
src/watch.zigmodified+561-105
Changes to src/watch.zig
@@ -3,199 +3,655 @@ const discovery = ;
33const test_loader = ;
44const compat = ;
55
6/// Watch mode options
6/// Watch mode options.
77pub const WatchOptions = struct {
8 /// Directory to watch
8 /// Directory containing discoverable tests.
99 watch_dir: []const u8 = ".",
10 /// Test file pattern
10 /// Project root scanned for Zig source changes.
11 project_root: []const u8 = ".",
12 /// Test file pattern.
1113 pattern: []const u8 = "*.test.zig",
12 /// Whether to watch recursively
14 /// Whether test discovery is recursive.
1315 recursive: bool = true,
14 /// Debounce delay in milliseconds
16 /// Debounce delay in milliseconds.
1517 debounce_ms: u64 = 300,
16 /// Clear screen between runs
18 /// Clear screen between runs.
1719 clear_screen: bool = true,
18 /// Verbose output
20 /// Verbose output.
1921 verbose: bool = false,
22 /// Listen for `r` followed by Enter to request a full rerun.
23 interactive_commands: bool = false,
2024};
2125
22/// File watcher for test files
26pub const ChangeKind = enum { added, modified, deleted };
27
28pub const FileChange = struct {
29 path: []const u8,
30 kind: ChangeKind,
31};
32
33const ChangeSet = struct {
34 allocator: std.mem.Allocator,
35 items: std.ArrayList(FileChange) = .empty,
36
37 fn deinit(self: *ChangeSet) void {
38 for (self.items.items) |change| self.allocator.free(change.path);
39 self.items.deinit(self.allocator);
40 }
41
42 fn append(self: *ChangeSet, path: []const u8, kind: ChangeKind) !void {
43 const path_copy = try self.allocator.dupe(u8, path);
44 errdefer self.allocator.free(path_copy);
45 try self.items.append(self.allocator, .{
46 .path = path_copy,
47 .kind = kind,
48 });
49 }
50};
51
52const FileStamp = struct {
53 modified_ns: i96,
54 size: u64,
55
56 fn eql(a: FileStamp, b: FileStamp) bool {
57 return a.modified_ns == b.modified_ns and a.size == b.size;
58 }
59};
60
61const Snapshot = struct {
62 allocator: std.mem.Allocator,
63 files: std.StringHashMap(FileStamp),
64
65 fn init(allocator: std.mem.Allocator) Snapshot {
66 return .{ .allocator = allocator, .files = .init(allocator) };
67 }
68
69 fn deinit(self: *Snapshot) void {
70 var keys = self.files.keyIterator();
71 while (keys.next()) |path| self.allocator.free(path.*);
72 self.files.deinit();
73 }
74
75 fn put(self: *Snapshot, path: []u8, stamp: FileStamp) !void {
76 const entry = try self.files.getOrPut(path);
77 if (entry.found_existing) {
78 self.allocator.free(path);
79 } else {
80 entry.key_ptr.* = path;
81 }
82 entry.value_ptr.* = stamp;
83 }
84
85 fn changesSince(self: *const Snapshot, previous: *const Snapshot, allocator: std.mem.Allocator) !ChangeSet {
86 var changes = ChangeSet{ .allocator = allocator };
87 errdefer changes.deinit();
88
89 var current = self.files.iterator();
90 while (current.next()) |entry| {
91 if (previous.files.get(entry.key_ptr.*)) |old_stamp| {
92 if (!entry.value_ptr.eql(old_stamp)) try changes.append(entry.key_ptr.*, .modified);
93 } else {
94 try changes.append(entry.key_ptr.*, .added);
95 }
96 }
97
98 var old = previous.files.iterator();
99 while (old.next()) |entry| {
100 if (!self.files.contains(entry.key_ptr.*)) try changes.append(entry.key_ptr.*, .deleted);
101 }
102 return changes;
103 }
104};
105
106const SelectionReason = enum { changed_test, affected_source };
107
108const SelectedTest = struct {
109 index: usize,
110 reason: SelectionReason,
111 cause: []const u8,
112};
113
114const FullRunReason = enum {
115 initial,
116 manual,
117 test_topology_changed,
118 dependency_information_unavailable,
119};
120
121const Selection = struct {
122 allocator: std.mem.Allocator,
123 full_run: ?FullRunReason = null,
124 tests: std.ArrayList(SelectedTest) = .empty,
125
126 fn deinit(self: *Selection) void {
127 self.tests.deinit(self.allocator);
128 }
129
130 fn select(self: *Selection, index: usize, reason: SelectionReason, cause: []const u8) !void {
131 for (self.tests.items) |existing| {
132 if (existing.index == index) return;
133 }
134 try self.tests.append(self.allocator, .{ .index = index, .reason = reason, .cause = cause });
135 }
136};
137
138const DependencyInfo = struct {
139 allocator: std.mem.Allocator,
140 paths: std.StringHashMap(void),
141 complete: bool = true,
142
143 fn init(allocator: std.mem.Allocator) DependencyInfo {
144 return .{ .allocator = allocator, .paths = .init(allocator) };
145 }
146
147 fn deinit(self: *DependencyInfo) void {
148 var keys = self.paths.keyIterator();
149 while (keys.next()) |path| self.allocator.free(path.*);
150 self.paths.deinit();
151 }
152
153 fn add(self: *DependencyInfo, path: []u8) !bool {
154 const entry = try self.paths.getOrPut(path);
155 if (entry.found_existing) {
156 self.allocator.free(path);
157 return false;
158 }
159 entry.key_ptr.* = path;
160 entry.value_ptr.* = {};
161 return true;
162 }
163};
164
165var manual_rerun_requested = std.atomic.Value(bool).init(false);
166var command_listener_started = std.atomic.Value(bool).init(false);
167
168/// Polling watcher that selects only tests affected by a change when it can do
169/// so safely.
23170pub const TestWatcher = struct {
24171 allocator: std.mem.Allocator,
25172 options: WatchOptions,
26173 running: *std.atomic.Value(bool),
27 last_run_time: std.atomic.Value(i64) = std.atomic.Value(i64).init(0),
174 last_run_time: std.atomic.Value(i64) = .init(0),
28175
29176 const Self = @This();
30177
31178 pub fn init(allocator: std.mem.Allocator, options: WatchOptions, running: *std.atomic.Value(bool)) Self {
32 return .{
33 .allocator = allocator,
34 .options = options,
35 .running = running,
36 };
179 return .{ .allocator = allocator, .options = options, .running = running };
180 }
181
182 /// Ask a running watcher to execute every discovered test at its next poll.
183 pub fn requestFullRerun(_: *Self) void {
184 manual_rerun_requested.store(true, .release);
37185 }
38186
39 /// Start watching for file changes
187 /// Run tests initially, then poll source and test files for changes.
40188 pub fn watch(self: *Self, loader_options: test_loader.LoaderOptions) !void {
41189 if (self.options.verbose) {
42 std.debug.print("Watching for changes in '{s}' (pattern: {s})...\n", .{ self.options.watch_dir, self.options.pattern });
190 std.debug.print("Watching project '{s}' for tests in '{s}' (pattern: {s})...\n", .{
191 self.options.project_root,
192 self.options.watch_dir,
193 self.options.pattern,
194 });
195 }
196 if (self.options.interactive_commands) {
197 std.debug.print("Press Ctrl+C to stop; type 'r' and press Enter for a full rerun.\n\n", .{});
198 } else if (self.options.verbose) {
43199 std.debug.print("Press Ctrl+C to stop.\n\n", .{});
44200 }
45201
46 // Run tests initially
47 try self.runTests(loader_options);
48
49 // Watch for file changes
50 while (self.running.load(.monotonic)) {
51 compat.sleep(self.options.debounce_ms * std.time.ns_per_ms);
202 manual_rerun_requested.store(false, .release);
203 if (self.options.interactive_commands) startCommandListener();
52204
53 if (try self.checkForChanges()) {
54 const current_time = compat.milliTimestamp();
55 const last_run = self.last_run_time.load(.monotonic);
205 var snapshot = try captureSnapshot(self.allocator, self.options.project_root);
206 defer snapshot.deinit();
207 try self.runFull(loader_options, .initial);
208 self.last_run_time.store(compat.milliTimestamp(), .release);
56209
57 // Debounce: only run if enough time has passed
58 if (current_time - last_run >= self.options.debounce_ms) {
59 if (self.options.clear_screen) {
60 self.clearScreen();
61 }
210 while (self.running.load(.acquire)) {
211 compat.sleep(self.options.debounce_ms * std.time.ns_per_ms);
62212
63 std.debug.print("Change detected, re-running tests...\n\n", .{});
213 var current = try captureSnapshot(self.allocator, self.options.project_root);
214 var changes = try current.changesSince(&snapshot, self.allocator);
215 snapshot.deinit();
216 snapshot = current;
217 defer changes.deinit();
64218
65 try self.runTests(loader_options);
219 const manual = manual_rerun_requested.swap(false, .acq_rel);
220 if (changes.items.items.len == 0 and !manual) continue;
66221
67 self.last_run_time.store(current_time, .monotonic);
68 }
222 if (self.options.clear_screen) self.clearScreen();
223 if (manual) {
224 try self.runFull(loader_options, .manual);
225 } else {
226 try self.runAffected(loader_options, changes.items.items);
69227 }
228 self.last_run_time.store(compat.milliTimestamp(), .release);
70229 }
71230 }
72231
73 /// Check if any test files have changed
74 fn checkForChanges(self: *Self) !bool {
75 // TODO: Re-implement with std.Io.Dir when Io is available in Zig 0.16
76 _ = self;
77 return false;
232 fn runFull(self: *Self, loader_options: test_loader.LoaderOptions, reason: FullRunReason) !void {
233 printFullRunReason(reason);
234 var discovered = try self.discover();
235 defer discovered.deinit();
236 printOutcome(try test_loader.runDiscoveredTests(self.allocator, &discovered, loader_options));
78237 }
79238
80 /// Run tests with discovery
81 fn runTests(self: *Self, loader_options: test_loader.LoaderOptions) !void {
82 const discovery_options = discovery.DiscoveryOptions{
239 fn runAffected(self: *Self, loader_options: test_loader.LoaderOptions, changes: []const FileChange) !void {
240 var discovered = try self.discover();
241 defer discovered.deinit();
242 var selection = try planAffected(self.allocator, self.options, &discovered, changes);
243 defer selection.deinit();
244
245 if (selection.full_run) |reason| {
246 printChanges(changes);
247 printFullRunReason(reason);
248 printOutcome(try test_loader.runDiscoveredTests(self.allocator, &discovered, loader_options));
249 return;
250 }
251
252 var selected = discovery.DiscoveryResult.init(self.allocator);
253 defer selected.deinit();
254 std.debug.print("Watch selection:\n", .{});
255 for (selection.tests.items) |item| {
256 const file = discovered.files.items[item.index];
257 try selected.addFile(file.path, file.relative_path, file.name);
258 std.debug.print(" - {s} ({s}: {s})\n", .{
259 file.relative_path,
260 switch (item.reason) {
261 .changed_test => "changed test file",
262 .affected_source => "imports changed source",
263 },
264 item.cause,
265 });
266 }
267 std.debug.print("\n", .{});
268 printOutcome(try test_loader.runDiscoveredTests(self.allocator, &selected, loader_options));
269 }
270
271 fn discover(self: *Self) !discovery.DiscoveryResult {
272 return discovery.discoverTests(self.allocator, .{
83273 .root_path = self.options.watch_dir,
84274 .pattern = self.options.pattern,
85275 .recursive = self.options.recursive,
86 };
276 });
277 }
87278
88 var discovered = try discovery.discoverTests(self.allocator, discovery_options);
89 defer discovered.deinit();
279 fn clearScreen(_: *Self) void {
280 std.debug.print("\x1b[2J\x1b[H", .{});
281 }
282};
90283
91 const passed = try test_loader.runDiscoveredTests(self.allocator, &discovered, loader_options);
284fn printOutcome(passed: bool) void {
285 if (passed) {
286 std.debug.print("\n✓ Selected tests passed. Watching for changes...\n", .{});
287 } else {
288 std.debug.print("\n✗ Selected tests failed. Fix them and save to re-run.\n", .{});
289 }
290}
92291
93 if (passed) {
94 std.debug.print("\n✓ All tests passed! Watching for changes...\n", .{});
95 } else {
96 std.debug.print("\n✗ Some tests failed. Fix them and save to re-run.\n", .{});
292fn printChanges(changes: []const FileChange) void {
293 std.debug.print("Detected changes:\n", .{});
294 for (changes) |change| std.debug.print(" - {s}: {s}\n", .{ @tagName(change.kind), change.path });
295}
296
297fn printFullRunReason(reason: FullRunReason) void {
298 const message = switch (reason) {
299 .initial => "initial watch run",
300 .manual => "manual full-rerun command",
301 .test_topology_changed => "a test was deleted or renamed",
302 .dependency_information_unavailable => "dependency information was incomplete or no affected test was found",
303 };
304 std.debug.print("Watch selection: full suite ({s})\n\n", .{message});
305}
306
307fn startCommandListener() void {
308 if (command_listener_started.cmpxchgStrong(false, true, .acq_rel, .acquire) != null) return;
309 const thread = std.Thread.spawn(.{}, commandLoop, .{}) catch {
310 command_listener_started.store(false, .release);
311 return;
312 };
313 thread.detach();
314}
315
316fn commandLoop() void {
317 var threaded: std.Io.Threaded = .init(std.heap.page_allocator, .{ .environ = .empty });
318 defer threaded.deinit();
319 var buffer: [256]u8 = undefined;
320 var reader = std.Io.File.stdin().reader(threaded.io(), &buffer);
321 while (true) {
322 const raw = (reader.interface.takeDelimiter('\n') catch return) orelse return;
323 const command = std.mem.trim(u8, raw, " \t\r");
324 if (std.ascii.eqlIgnoreCase(command, "r") or std.ascii.eqlIgnoreCase(command, "run all")) {
325 manual_rerun_requested.store(true, .release);
97326 }
98327 }
328}
99329
100 /// Check if filename matches pattern
101 fn matchesPattern(self: *Self, filename: []const u8, pattern: []const u8) bool {
102 _ = self;
330fn captureSnapshot(allocator: std.mem.Allocator, root: []const u8) !Snapshot {
331 var snapshot = Snapshot.init(allocator);
332 errdefer snapshot.deinit();
333 var threaded: std.Io.Threaded = .init(allocator, .{ .environ = .empty });
334 defer threaded.deinit();
335 try scanSnapshot(allocator, &snapshot, threaded.io(), root);
336 return snapshot;
337}
103338
104 // Simple wildcard matching
105 if (std.mem.indexOf(u8, pattern, "*")) |star_pos| {
106 const prefix = pattern[0..star_pos];
107 const suffix = pattern[star_pos + 1 ..];
339fn scanSnapshot(allocator: std.mem.Allocator, snapshot: *Snapshot, io: std.Io, current_path: []const u8) !void {
340 var dir = compat.DirIterator.open(current_path) catch return;
341 defer dir.close();
342
343 while (try dir.next()) |entry| {
344 if (entry.kind == .directory and shouldSkipDirectory(entry.name)) continue;
345 const joined = try std.fs.path.join(allocator, &.{ current_path, entry.name });
346 defer allocator.free(joined);
347
348 switch (entry.kind) {
349 .directory => try scanSnapshot(allocator, snapshot, io, joined),
350 .file => {
351 if (!std.mem.endsWith(u8, entry.name, ".zig")) continue;
352 const normalized = try std.fs.path.resolveAlloc(allocator, &.{joined});
353 errdefer allocator.free(normalized);
354 const stat = std.Io.Dir.cwd().statFile(io, normalized, .{}) catch {
355 allocator.free(normalized);
356 continue;
357 };
358 try snapshot.put(normalized, .{ .modified_ns = stat.mtime.nanoseconds, .size = stat.size });
359 },
360 else => {},
361 }
362 }
363}
108364
109 if (prefix.len > 0 and !std.mem.startsWith(u8, filename, prefix)) {
110 return false;
365fn shouldSkipDirectory(name: []const u8) bool {
366 const excluded = [_][]const u8{ ".git", ".zig-cache", "zig-cache", "zig-out", "node_modules", ".codex" };
367 for (excluded) |candidate| {
368 if (std.mem.eql(u8, name, candidate)) return true;
369 }
370 return false;
371}
372
373fn planAffected(
374 allocator: std.mem.Allocator,
375 options: WatchOptions,
376 discovered: *const discovery.DiscoveryResult,
377 changes: []const FileChange,
378) !Selection {
379 var selection = Selection{ .allocator = allocator };
380 errdefer selection.deinit();
381 var sources: std.ArrayList([]const u8) = .empty;
382 defer sources.deinit(allocator);
383
384 for (changes) |change| {
385 if (isTestPath(change.path, options.pattern)) {
386 if (change.kind == .deleted) {
387 selection.full_run = .test_topology_changed;
388 return selection;
389 }
390 if (findTest(discovered, change.path, allocator)) |index| {
391 try selection.select(index, .changed_test, change.path);
392 } else {
393 selection.full_run = .test_topology_changed;
394 return selection;
111395 }
396 } else {
397 try sources.append(allocator, change.path);
398 }
399 }
112400
113 if (suffix.len > 0 and !std.mem.endsWith(u8, filename, suffix)) {
114 return false;
401 if (sources.items.len == 0) return selection;
402 const mapped = try allocator.alloc(bool, sources.items.len);
403 defer allocator.free(mapped);
404 @memset(mapped, false);
405
406 for (discovered.files.items, 0..) |file, index| {
407 var dependencies = try resolveDependencies(allocator, file.path);
408 defer dependencies.deinit();
409 if (!dependencies.complete) {
410 selection.full_run = .dependency_information_unavailable;
411 return selection;
412 }
413 for (sources.items, 0..) |source, source_index| {
414 if (dependencies.paths.contains(source)) {
415 mapped[source_index] = true;
416 try selection.select(index, .affected_source, source);
115417 }
418 }
419 }
116420
117 return true;
118 } else {
119 return std.mem.eql(u8, filename, pattern);
421 for (mapped) |was_mapped| {
422 if (!was_mapped) {
423 selection.full_run = .dependency_information_unavailable;
424 return selection;
120425 }
121426 }
427 return selection;
428}
122429
123 /// Clear terminal screen
124 fn clearScreen(self: *Self) void {
125 _ = self;
126 // ANSI escape code to clear screen
127 std.debug.print("\x1b[2J\x1b[H", .{});
430fn findTest(discovered: *const discovery.DiscoveryResult, changed_path: []const u8, allocator: std.mem.Allocator) ?usize {
431 for (discovered.files.items, 0..) |file, index| {
432 const normalized = std.fs.path.resolveAlloc(allocator, &.{file.path}) catch continue;
433 defer allocator.free(normalized);
434 if (std.mem.eql(u8, normalized, changed_path)) return index;
128435 }
129};
436 return null;
437}
130438
131/// Simple file watcher using polling
132/// Note: In production, use std.fs.Watch or platform-specific APIs (inotify, kqueue, etc.)
439fn isTestPath(path: []const u8, pattern: []const u8) bool {
440 const name = std.fs.path.basename(path);
441 if (std.mem.indexOfScalar(u8, pattern, '*')) |star| {
442 const prefix = pattern[0..star];
443 const suffix = pattern[star + 1 ..];
444 return std.mem.startsWith(u8, name, prefix) and std.mem.endsWith(u8, name, suffix);
445 }
446 return std.mem.eql(u8, name, pattern);
447}
448
449fn resolveDependencies(allocator: std.mem.Allocator, test_path: []const u8) !DependencyInfo {
450 var info = DependencyInfo.init(allocator);
451 errdefer info.deinit();
452 const normalized = try std.fs.path.resolveAlloc(allocator, &.{test_path});
453 defer allocator.free(normalized);
454 try scanImports(&info, normalized);
455 if (info.paths.fetchRemove(normalized)) |removed| allocator.free(removed.key);
456 return info;
457}
458
459fn scanImports(info: *DependencyInfo, importer_path: []const u8) !void {
460 const content = compat.readFileAlloc(info.allocator, importer_path) catch {
461 info.complete = false;
462 return;
463 };
464 defer info.allocator.free(content);
465
466 const marker = ") |start| {
469 const value_start = start + marker.len;
470 const value_end = std.mem.indexOfScalarPos(u8, content, value_start, '"') orelse {
471 info.complete = false;
472 return;
473 };
474 const import_name = content[value_start..value_end];
475 cursor = value_end + 1;
476
477 if (std.mem.eql(u8, import_name, "std") or
478 std.mem.eql(u8, import_name, "builtin") or
479 std.mem.eql(u8, import_name, "root")) continue;
480
481 if (!std.mem.endsWith(u8, import_name, ".zig") or
482 std.mem.indexOfScalar(u8, import_name, '\\') != null)
483 {
484 info.complete = false;
485 continue;
486 }
487
488 const parent = std.fs.path.dirname(importer_path) orelse ".";
489 const dependency = try std.fs.path.resolveAlloc(info.allocator, &.{ parent, import_name });
490 if (try info.add(dependency)) try scanImports(info, dependency);
491 }
492}
493
494/// Simple watcher for explicitly registered paths.
133495pub const FileWatcher = struct {
134496 allocator: std.mem.Allocator,
135497 watch_paths: std.ArrayList([]const u8),
136 file_times: std.StringHashMap(i64),
498 file_stamps: std.StringHashMap(?FileStamp),
137499
138500 const Self = @This();
139501
140502 pub fn init(allocator: std.mem.Allocator) Self {
141 return .{
142 .allocator = allocator,
143 .watch_paths = std.ArrayList([]const u8).empty,
144 .file_times = std.StringHashMap(i64).init(allocator),
145 };
503 return .{ .allocator = allocator, .watch_paths = .empty, .file_stamps = .init(allocator) };
146504 }
147505
148506 pub fn deinit(self: *Self) void {
149 for (self.watch_paths.items) |path| {
150 self.allocator.free(path);
151 }
507 for (self.watch_paths.items) |path| self.allocator.free(path);
152508 self.watch_paths.deinit(self.allocator);
153 self.file_times.deinit();
509 self.file_stamps.deinit();
154510 }
155511
156 /// Add a path to watch
157512 pub fn addPath(self: *Self, path: []const u8) !void {
158513 const path_copy = try self.allocator.dupe(u8, path);
514 errdefer self.allocator.free(path_copy);
159515 try self.watch_paths.append(self.allocator, path_copy);
160
161 // Initialize modification time
162 // TODO: statFile needs Io in Zig 0.16
163 try self.file_times.put(path_copy, 0);
516 errdefer _ = self.watch_paths.pop();
517 var threaded: std.Io.Threaded = .init(self.allocator, .{ .environ = .empty });
518 defer threaded.deinit();
519 const initial: ?FileStamp = if (std.Io.Dir.cwd().statFile(threaded.io(), path_copy, .{})) |stat|
520 .{ .modified_ns = stat.mtime.nanoseconds, .size = stat.size }
521 else |_|
522 null;
523 try self.file_stamps.put(path_copy, initial);
164524 }
165525
166 /// Check if any watched files have changed
167526 pub fn hasChanges(self: *Self) !bool {
527 var threaded: std.Io.Threaded = .init(self.allocator, .{ .environ = .empty });
528 defer threaded.deinit();
529 var changed = false;
168530 for (self.watch_paths.items) |path| {
169 // TODO: statFile needs Io in Zig 0.16
170 const mtime_ms: i64 = 0;
171 const last_mtime = self.file_times.get(path) orelse 0;
172
173 if (mtime_ms > last_mtime) {
174 try self.file_times.put(path, mtime_ms);
175 return true;
176 }
531 const current: ?FileStamp = if (std.Io.Dir.cwd().statFile(threaded.io(), path, .{})) |stat|
532 .{ .modified_ns = stat.mtime.nanoseconds, .size = stat.size }
533 else |_|
534 null;
535 const previous = self.file_stamps.get(path) orelse null;
536 if (!std.meta.eql(current, previous)) changed = true;
537 try self.file_stamps.put(path, current);
177538 }
178
179 return false;
539 return changed;
180540 }
181541};
182542
183// Tests
184543test "WatchOptions default values" {
185544 const options = WatchOptions{};
186
187545 try std.testing.expectEqualStrings(".", options.watch_dir);
546 try std.testing.expectEqualStrings(".", options.project_root);
188547 try std.testing.expectEqualStrings("*.test.zig", options.pattern);
189 try std.testing.expectEqual(true, options.recursive);
548 try std.testing.expect(options.recursive);
190549 try std.testing.expectEqual(@as(u64, 300), options.debounce_ms);
191 try std.testing.expectEqual(true, options.clear_screen);
550 try std.testing.expect(options.clear_screen);
551 try std.testing.expect(!options.interactive_commands);
192552}
193553
194test "FileWatcher initialization" {
554test "test pattern classification handles wildcard and exact patterns" {
555 try std.testing.expect(isTestPath("tests/math.test.zig", "*.test.zig"));
556 try std.testing.expect(!isTestPath("src/math.zig", "*.test.zig"));
557 try std.testing.expect(isTestPath("tests/specific.zig", "specific.zig"));
558}
559
560test "snapshot diff reports additions modifications and deletions" {
195561 const allocator = std.testing.allocator;
562 var old = Snapshot.init(allocator);
563 defer old.deinit();
564 try old.put(try allocator.dupe(u8, "deleted.zig"), .{ .modified_ns = 1, .size = 1 });
565 try old.put(try allocator.dupe(u8, "modified.zig"), .{ .modified_ns = 1, .size = 1 });
566 var current = Snapshot.init(allocator);
567 defer current.deinit();
568 try current.put(try allocator.dupe(u8, "added.zig"), .{ .modified_ns = 1, .size = 1 });
569 try current.put(try allocator.dupe(u8, "modified.zig"), .{ .modified_ns = 2, .size = 1 });
570
571 var changes = try current.changesSince(&old, allocator);
572 defer changes.deinit();
573 try std.testing.expectEqual(@as(usize, 3), changes.items.items.len);
574}
196575
197 var watcher = FileWatcher.init(allocator);
198 defer watcher.deinit();
576test "affected selection handles direct tests dependencies and safe fallbacks" {
577 const allocator = std.testing.allocator;
578 var tmp = std.testing.tmpDir(.{});
579 defer tmp.cleanup();
580 const root = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{tmp.sub_path});
581 defer allocator.free(root);
582 const tests_dir = try std.fs.path.join(allocator, &.{ root, "tests" });
583 defer allocator.free(tests_dir);
584 const src_dir = try std.fs.path.join(allocator, &.{ root, "src" });
585 defer allocator.free(src_dir);
586 try compat.makePath(allocator, tests_dir);
587 try compat.makePath(allocator, src_dir);
588
589 const helper_path = try std.fs.path.join(allocator, &.{ src_dir, "helper.zig" });
590 defer allocator.free(helper_path);
591 const math_path = try std.fs.path.join(allocator, &.{ src_dir, "math.zig" });
592 defer allocator.free(math_path);
593 const test_path = try std.fs.path.join(allocator, &.{ tests_dir, "math.test.zig" });
594 defer allocator.free(test_path);
595 try compat.writeFile(allocator, helper_path, "pub const value = 1;\n");
596 try compat.writeFile(allocator, math_path, "pub const helper = ;\n");
597 try compat.writeFile(allocator, test_path, "const math = ;\ntest \"math\" { _ = math; }\n");
598
599 var discovered = discovery.DiscoveryResult.init(allocator);
600 defer discovered.deinit();
601 try discovered.addFile(test_path, "math.test.zig", "math.test.zig");
602 const normalized_test = try std.fs.path.resolveAlloc(allocator, &.{test_path});
603 defer allocator.free(normalized_test);
604 const normalized_helper = try std.fs.path.resolveAlloc(allocator, &.{helper_path});
605 defer allocator.free(normalized_helper);
606 const options = WatchOptions{ .watch_dir = tests_dir, .project_root = root };
607
608 var direct = try planAffected(allocator, options, &discovered, &.{.{
609 .path = normalized_test,
610 .kind = .modified,
611 }});
612 defer direct.deinit();
613 try std.testing.expect(direct.full_run == null);
614 try std.testing.expectEqual(@as(usize, 1), direct.tests.items.len);
615 try std.testing.expectEqual(SelectionReason.changed_test, direct.tests.items[0].reason);
616
617 var added = try planAffected(allocator, options, &discovered, &.{.{
618 .path = normalized_test,
619 .kind = .added,
620 }});
621 defer added.deinit();
622 try std.testing.expect(added.full_run == null);
623 try std.testing.expectEqual(@as(usize, 1), added.tests.items.len);
624
625 var dependency = try planAffected(allocator, options, &discovered, &.{.{
626 .path = normalized_helper,
627 .kind = .modified,
628 }});
629 defer dependency.deinit();
630 try std.testing.expect(dependency.full_run == null);
631 try std.testing.expectEqual(@as(usize, 1), dependency.tests.items.len);
632 try std.testing.expectEqual(SelectionReason.affected_source, dependency.tests.items[0].reason);
633
634 var deleted = try planAffected(allocator, options, &discovered, &.{.{
635 .path = normalized_test,
636 .kind = .deleted,
637 }});
638 defer deleted.deinit();
639 try std.testing.expectEqual(FullRunReason.test_topology_changed, deleted.full_run.?);
640
641 const opaque_path = try std.fs.path.join(allocator, &.{ tests_dir, "opaque.test.zig" });
642 defer allocator.free(opaque_path);
643 try compat.writeFile(allocator, opaque_path, "const app = ;\ntest \"opaque\" { _ = app; }\n");
644 try discovered.addFile(opaque_path, "opaque.test.zig", "opaque.test.zig");
645 var fallback = try planAffected(allocator, options, &discovered, &.{.{
646 .path = normalized_helper,
647 .kind = .modified,
648 }});
649 defer fallback.deinit();
650 try std.testing.expectEqual(FullRunReason.dependency_information_unavailable, fallback.full_run.?);
651}
199652
653test "FileWatcher initialization" {
654 var watcher = FileWatcher.init(std.testing.allocator);
655 defer watcher.deinit();
200656 try std.testing.expectEqual(@as(usize, 0), watcher.watch_paths.items.len);
201657}