lib/memtrace/src/cli.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const pretty = @import("pretty");
3 const pretty_usage = @import("pretty_usage");
4 const sys = @import("sys");
5 const analysis = @import("analysis.zig");
6 const causal = @import("causal.zig");
7 const event = @import("event.zig");
8 const stack = @import("stack/root.zig");
9
10 const Allocator = std.mem.Allocator;
11
12 const usage_summary =
13 "memtrace summary <events.jsonl> [--sort retained|traffic|lifetime] " ++
14 "[--layer backing|logical|physical|all] " ++
15 "[--format jsonl] [--top N] [--min-bytes N] [--include-zero-live] " ++
16 "[--include-sites] [--site-detail ADDRESS] [--symbolize BINARY]";
17 const usage_allocations =
18 "memtrace allocations <events.jsonl> [--scope PATH] [--from-seq N] " ++
19 "[--to-seq N] [--layer backing|logical|physical|all] [--binary BINARY] " ++
20 "[--top N] [--frames N] [--format jsonl]";
21 const usage_operations =
22 "memtrace operations <events.jsonl> [--scope PATH] [--from-seq N] " ++
23 "[--to-seq N] [--layer backing|logical|physical|all] [--binary BINARY] " ++
24 "[--top N] [--frames N] [--format jsonl]";
25 const usage_operation =
26 "memtrace operation <events.jsonl> <operation-id> [--binary BINARY] " ++
27 "[--frames N] [--format jsonl]";
28 const usage_roots =
29 "memtrace roots <events.jsonl> [--scope PATH] [--from-seq N] " ++
30 "[--to-seq N] [--selection allocations|all] " ++
31 "[--sort high-water|backing-bytes|requested-bytes|roots] " ++
32 "[--detail summary|sources|full] [--binary BINARY] [--top N] " ++
33 "[--frames N] [--format jsonl]";
34 const usage_budget = "memtrace budget <roots.jsonl> <budget.json> [--format jsonl]";
35
36 const SummaryArgs = struct {
37 path: []const u8,
38 format: Format = .text,
39 options: analysis.SummaryOptions = .{},
40 };
41
42 const AllocationArgs = struct {
43 path: []const u8,
44 options: stack.report.Options = .{},
45 };
46
47 const OperationArgs = struct {
48 path: []const u8,
49 operation_id: u64,
50 options: causal.Options = .{},
51 };
52
53 const RootArgs = struct {
54 path: []const u8,
55 options: stack.roots.Options = .{},
56 };
57
58 const BudgetArgs = struct {
59 report_path: []const u8,
60 budget_path: []const u8,
61 format: stack.budget.Format = .text,
62 };
63
64 const Format = enum {
65 text,
66 jsonl,
67 };
68
69 pub fn dispatch(allocator: Allocator, args: []const []const u8) !u8 {
70 if (args.len < 2) {
71 try writeUsage(allocator, sys.stdio.stderr());
72 return 1;
73 }
74
75 const command = args[1];
76 if (std.mem.eql(u8, command, "summary")) return try summary(allocator, args[2..]);
77 if (std.mem.eql(u8, command, "operation")) {
78 return try operation(allocator, args[2..]);
79 }
80 if (std.mem.eql(u8, command, "allocations")) {
81 return try operations(allocator, args[2..], .allocations);
82 }
83 if (std.mem.eql(u8, command, "operations")) {
84 return try operations(allocator, args[2..], .all);
85 }
86 if (std.mem.eql(u8, command, "roots")) {
87 return try roots(allocator, args[2..]);
88 }
89 if (std.mem.eql(u8, command, "budget")) {
90 return try budget(allocator, args[2..]);
91 }
92 if (std.mem.eql(u8, command, "help") or std.mem.eql(u8, command, "--help") or std.mem.eql(u8, command, "-h")) {
93 try writeUsage(allocator, sys.stdio.stdout());
94 return 0;
95 }
96 return error.UnknownCommand;
97 }
98
99 fn roots(allocator: Allocator, args: []const []const u8) !u8 {
100 const parsed = try parseRootArgs(args);
101 var stdout_buffer: [64 * 1024]u8 = undefined;
102 var stdout = sys.stdio.stdout().writer(
103 sys.stdio.debugIo(),
104 &stdout_buffer,
105 );
106 stack.roots.writeFromPath(
107 allocator,
108 parsed.path,
109 &stdout.interface,
110 parsed.options,
111 ) catch |err| {
112 try stdout.interface.flush();
113 return err;
114 };
115 try stdout.interface.flush();
116 return 0;
117 }
118
119 fn budget(allocator: Allocator, args: []const []const u8) !u8 {
120 const parsed = try parseBudgetArgs(args);
121 var stdout_buffer: [64 * 1024]u8 = undefined;
122 var stdout = sys.stdio.stdout().writer(
123 sys.stdio.debugIo(),
124 &stdout_buffer,
125 );
126 const outcome = try stack.budget.checkFromPaths(
127 allocator,
128 parsed.report_path,
129 parsed.budget_path,
130 &stdout.interface,
131 parsed.format,
132 );
133 try stdout.interface.flush();
134 return if (outcome.passed) 0 else 1;
135 }
136
137 fn operation(allocator: Allocator, args: []const []const u8) !u8 {
138 const parsed = try parseOperationArgs(args);
139 var stdout_buffer: [64 * 1024]u8 = undefined;
140 var stdout = sys.stdio.stdout().writer(
141 sys.stdio.debugIo(),
142 &stdout_buffer,
143 );
144 try causal.writeFromPath(
145 allocator,
146 parsed.path,
147 parsed.operation_id,
148 &stdout.interface,
149 parsed.options,
150 );
151 try stdout.interface.flush();
152 return 0;
153 }
154
155 fn operations(
156 allocator: Allocator,
157 args: []const []const u8,
158 selection: stack.report.Selection,
159 ) !u8 {
160 var parsed = try parseAllocationArgs(args);
161 parsed.options.selection = selection;
162 var stdout_buffer: [64 * 1024]u8 = undefined;
163 var stdout = sys.stdio.stdout().writer(
164 sys.stdio.debugIo(),
165 &stdout_buffer,
166 );
167 try stack.report.writeFromPath(
168 allocator,
169 parsed.path,
170 &stdout.interface,
171 parsed.options,
172 );
173 try stdout.interface.flush();
174 return 0;
175 }
176
177 pub fn printError(allocator: Allocator, file: sys.stdio.File, text: []const u8) !void {
178 try pretty_usage.Terminal.init(allocator, file, .{}).writeErrorText("memtrace", text);
179 }
180
181 fn summary(allocator: Allocator, args: []const []const u8) !u8 {
182 const parsed = try parseSummaryArgs(args);
183 var stdout_buffer: [8192]u8 = undefined;
184 var stdout = sys.stdio.stdout().writer(sys.stdio.debugIo(), &stdout_buffer);
185 switch (parsed.format) {
186 .text => try analysis.writeSummaryFromJsonlPath(allocator, parsed.path, &stdout.interface, parsed.options),
187 .jsonl => _ = try analysis.writeSummaryJsonlFromJsonlPath(
188 allocator,
189 parsed.path,
190 &stdout.interface,
191 parsed.options,
192 ),
193 }
194 try stdout.interface.flush();
195 return 0;
196 }
197
198 fn parseSummaryArgs(args: []const []const u8) !SummaryArgs {
199 if (args.len == 0) return error.InvalidArguments;
200 var parsed = SummaryArgs{ .path = args[0] };
201 var index: usize = 1;
202 while (index < args.len) : (index += 1) {
203 if (std.mem.eql(u8, args[index], "--format")) {
204 index += 1;
205 if (index >= args.len) return error.InvalidArguments;
206 if (std.mem.eql(u8, args[index], "jsonl")) {
207 parsed.format = .jsonl;
208 } else if (std.mem.eql(u8, args[index], "text")) {
209 parsed.format = .text;
210 } else {
211 return error.UnsupportedFormat;
212 }
213 } else if (std.mem.eql(u8, args[index], "--top")) {
214 index += 1;
215 if (index >= args.len) return error.InvalidArguments;
216 parsed.options.top = try parsePositiveUsize(args[index]);
217 } else if (std.mem.eql(u8, args[index], "--layer")) {
218 index += 1;
219 if (index >= args.len) return error.InvalidArguments;
220 parsed.options.layer = try parseLayer(args[index]);
221 } else if (std.mem.eql(u8, args[index], "--sort")) {
222 index += 1;
223 if (index >= args.len) return error.InvalidArguments;
224 parsed.options.sort = analysis.Sort.parse(args[index]) orelse
225 return error.UnsupportedSort;
226 } else if (std.mem.eql(u8, args[index], "--min-bytes")) {
227 index += 1;
228 if (index >= args.len) return error.InvalidArguments;
229 parsed.options.min_bytes = try parseUsize(args[index]);
230 } else if (std.mem.eql(u8, args[index], "--include-zero-live")) {
231 parsed.options.include_zero_live = true;
232 } else if (std.mem.eql(u8, args[index], "--include-sites")) {
233 parsed.options.include_sites = true;
234 } else if (std.mem.eql(u8, args[index], "--site-detail")) {
235 index += 1;
236 if (index >= args.len) return error.InvalidArguments;
237 parsed.options.site_detail_return_address = try parseAddress(args[index]);
238 } else if (std.mem.eql(u8, args[index], "--symbolize")) {
239 index += 1;
240 if (index >= args.len) return error.InvalidArguments;
241 parsed.options.include_sites = true;
242 parsed.options.site_symbol_binary = args[index];
243 } else {
244 return error.UnknownArgument;
245 }
246 }
247 return parsed;
248 }
249
250 fn parseAllocationArgs(args: []const []const u8) !AllocationArgs {
251 if (args.len == 0) return error.InvalidArguments;
252 var parsed = AllocationArgs{ .path = args[0] };
253 var index: usize = 1;
254 while (index < args.len) : (index += 1) {
255 if (std.mem.eql(u8, args[index], "--format")) {
256 index += 1;
257 if (index >= args.len) return error.InvalidArguments;
258 parsed.options.format = if (std.mem.eql(u8, args[index], "jsonl"))
259 .jsonl
260 else if (std.mem.eql(u8, args[index], "text"))
261 .text
262 else
263 return error.UnsupportedFormat;
264 } else if (std.mem.eql(u8, args[index], "--top")) {
265 index += 1;
266 if (index >= args.len) return error.InvalidArguments;
267 parsed.options.top = try parsePositiveUsize(args[index]);
268 } else if (std.mem.eql(u8, args[index], "--layer")) {
269 index += 1;
270 if (index >= args.len) return error.InvalidArguments;
271 parsed.options.layer = try parseLayer(args[index]);
272 } else if (std.mem.eql(u8, args[index], "--frames")) {
273 index += 1;
274 if (index >= args.len) return error.InvalidArguments;
275 parsed.options.frame_limit = try parsePositiveUsize(args[index]);
276 } else if (std.mem.eql(u8, args[index], "--scope")) {
277 index += 1;
278 if (index >= args.len) return error.InvalidArguments;
279 parsed.options.window.scope = args[index];
280 } else if (std.mem.eql(u8, args[index], "--from-seq")) {
281 index += 1;
282 if (index >= args.len) return error.InvalidArguments;
283 parsed.options.window.first_sequence =
284 try parsePositiveU64(args[index]);
285 } else if (std.mem.eql(u8, args[index], "--to-seq")) {
286 index += 1;
287 if (index >= args.len) return error.InvalidArguments;
288 parsed.options.window.last_sequence =
289 try parsePositiveU64(args[index]);
290 } else if (std.mem.eql(u8, args[index], "--binary") or
291 std.mem.eql(u8, args[index], "--symbolize"))
292 {
293 index += 1;
294 if (index >= args.len) return error.InvalidArguments;
295 parsed.options.binary_path = args[index];
296 } else {
297 return error.UnknownArgument;
298 }
299 }
300 try parsed.options.window.validate();
301 return parsed;
302 }
303
304 fn parseOperationArgs(args: []const []const u8) !OperationArgs {
305 if (args.len < 2) return error.InvalidArguments;
306 var parsed = OperationArgs{
307 .path = args[0],
308 .operation_id = try parsePositiveU64(args[1]),
309 };
310 var index: usize = 2;
311 while (index < args.len) : (index += 1) {
312 if (std.mem.eql(u8, args[index], "--format")) {
313 index += 1;
314 if (index >= args.len) return error.InvalidArguments;
315 parsed.options.format = if (std.mem.eql(u8, args[index], "jsonl"))
316 .jsonl
317 else if (std.mem.eql(u8, args[index], "text"))
318 .text
319 else
320 return error.UnsupportedFormat;
321 } else if (std.mem.eql(u8, args[index], "--frames")) {
322 index += 1;
323 if (index >= args.len) return error.InvalidArguments;
324 parsed.options.frame_limit = try parsePositiveUsize(args[index]);
325 } else if (std.mem.eql(u8, args[index], "--binary") or
326 std.mem.eql(u8, args[index], "--symbolize"))
327 {
328 index += 1;
329 if (index >= args.len) return error.InvalidArguments;
330 parsed.options.binary_path = args[index];
331 } else {
332 return error.UnknownArgument;
333 }
334 }
335 return parsed;
336 }
337
338 fn parseRootArgs(args: []const []const u8) !RootArgs {
339 if (args.len == 0) return error.InvalidArguments;
340 var parsed = RootArgs{ .path = args[0] };
341 var index: usize = 1;
342 while (index < args.len) : (index += 1) {
343 if (std.mem.eql(u8, args[index], "--format")) {
344 index += 1;
345 if (index >= args.len) return error.InvalidArguments;
346 parsed.options.format = if (std.mem.eql(u8, args[index], "jsonl"))
347 .jsonl
348 else if (std.mem.eql(u8, args[index], "text"))
349 .text
350 else
351 return error.UnsupportedFormat;
352 } else if (std.mem.eql(u8, args[index], "--top")) {
353 index += 1;
354 if (index >= args.len) return error.InvalidArguments;
355 parsed.options.top = try parsePositiveUsize(args[index]);
356 } else if (std.mem.eql(u8, args[index], "--frames")) {
357 index += 1;
358 if (index >= args.len) return error.InvalidArguments;
359 parsed.options.frame_limit = try parsePositiveUsize(args[index]);
360 } else if (std.mem.eql(u8, args[index], "--scope")) {
361 index += 1;
362 if (index >= args.len) return error.InvalidArguments;
363 parsed.options.window.scope = args[index];
364 } else if (std.mem.eql(u8, args[index], "--from-seq")) {
365 index += 1;
366 if (index >= args.len) return error.InvalidArguments;
367 parsed.options.window.first_sequence =
368 try parsePositiveU64(args[index]);
369 } else if (std.mem.eql(u8, args[index], "--to-seq")) {
370 index += 1;
371 if (index >= args.len) return error.InvalidArguments;
372 parsed.options.window.last_sequence =
373 try parsePositiveU64(args[index]);
374 } else if (std.mem.eql(u8, args[index], "--selection")) {
375 index += 1;
376 if (index >= args.len) return error.InvalidArguments;
377 parsed.options.selection = if (std.mem.eql(
378 u8,
379 args[index],
380 "allocations",
381 ))
382 .allocations
383 else if (std.mem.eql(u8, args[index], "all"))
384 .all
385 else
386 return error.UnsupportedSelection;
387 } else if (std.mem.eql(u8, args[index], "--sort")) {
388 index += 1;
389 if (index >= args.len) return error.InvalidArguments;
390 parsed.options.sort = if (std.mem.eql(
391 u8,
392 args[index],
393 "high-water",
394 ))
395 .high_water
396 else if (std.mem.eql(u8, args[index], "backing-bytes"))
397 .backing_bytes
398 else if (std.mem.eql(u8, args[index], "requested-bytes"))
399 .requested_bytes
400 else if (std.mem.eql(u8, args[index], "roots"))
401 .roots
402 else
403 return error.UnsupportedSort;
404 } else if (std.mem.eql(u8, args[index], "--detail")) {
405 index += 1;
406 if (index >= args.len) return error.InvalidArguments;
407 parsed.options.detail = if (std.mem.eql(
408 u8,
409 args[index],
410 "summary",
411 ))
412 .summary
413 else if (std.mem.eql(u8, args[index], "sources"))
414 .sources
415 else if (std.mem.eql(u8, args[index], "full"))
416 .full
417 else
418 return error.UnsupportedDetail;
419 } else if (std.mem.eql(u8, args[index], "--binary") or
420 std.mem.eql(u8, args[index], "--symbolize"))
421 {
422 index += 1;
423 if (index >= args.len) return error.InvalidArguments;
424 parsed.options.binary_path = args[index];
425 } else {
426 return error.UnknownArgument;
427 }
428 }
429 try parsed.options.window.validate();
430 return parsed;
431 }
432
433 fn parseBudgetArgs(args: []const []const u8) !BudgetArgs {
434 if (args.len < 2) return error.InvalidArguments;
435 var parsed = BudgetArgs{
436 .report_path = args[0],
437 .budget_path = args[1],
438 };
439 var index: usize = 2;
440 while (index < args.len) : (index += 1) {
441 if (!std.mem.eql(u8, args[index], "--format")) {
442 return error.UnknownArgument;
443 }
444 index += 1;
445 if (index >= args.len) return error.InvalidArguments;
446 parsed.format = if (std.mem.eql(u8, args[index], "jsonl"))
447 .jsonl
448 else if (std.mem.eql(u8, args[index], "text"))
449 .text
450 else
451 return error.UnsupportedFormat;
452 }
453 return parsed;
454 }
455
456 fn parseLayer(text: []const u8) !event.LayerFilter {
457 return event.LayerFilter.fromTag(text) orelse error.UnsupportedLayer;
458 }
459
460 fn parsePositiveUsize(text: []const u8) !usize {
461 const value = try parseUsize(text);
462 if (value == 0) return error.InvalidArguments;
463 return value;
464 }
465
466 fn parsePositiveU64(text: []const u8) !u64 {
467 const value = std.fmt.parseUnsigned(u64, text, 10) catch
468 return error.InvalidArguments;
469 if (value == 0) return error.InvalidArguments;
470 return value;
471 }
472
473 fn parseUsize(text: []const u8) !usize {
474 return std.fmt.parseUnsigned(usize, text, 10) catch error.InvalidArguments;
475 }
476
477 fn parseAddress(text: []const u8) !u64 {
478 if (std.mem.startsWith(u8, text, "0x") or std.mem.startsWith(u8, text, "0X")) {
479 return std.fmt.parseUnsigned(u64, text[2..], 16) catch error.InvalidArguments;
480 }
481 return std.fmt.parseUnsigned(u64, text, 10) catch error.InvalidArguments;
482 }
483
484 fn writeUsage(allocator: Allocator, file: sys.stdio.File) !void {
485 var arena_state = std.heap.ArenaAllocator.init(allocator);
486 defer arena_state.deinit();
487 const builder = pretty.Builder.init(arena_state.allocator());
488 try pretty_usage.Terminal.init(allocator, file, .{}).writeDoc(try usageDocAlloc(builder));
489 }
490
491 fn renderUsageAlloc(allocator: Allocator, options: pretty.LayoutOptions) ![]u8 {
492 var arena_state = std.heap.ArenaAllocator.init(allocator);
493 defer arena_state.deinit();
494 const builder = pretty.Builder.init(arena_state.allocator());
495 const doc = try usageDocAlloc(builder);
496 return try pretty.renderAlloc(allocator, doc, options);
497 }
498
499 fn usageDocAlloc(builder: pretty.Builder) !pretty.Doc {
500 return try builder.concat(&.{
501 try builder.styledText(.keyword, "usage"),
502 try builder.punct(":"),
503 pretty.hardline,
504 try builder.spaces(2),
505 try builder.nest(4, try builder.styledWords(.name, usage_summary)),
506 pretty.hardline,
507 try builder.spaces(2),
508 try builder.nest(
509 4,
510 try builder.styledWords(.name, usage_allocations),
511 ),
512 pretty.hardline,
513 try builder.spaces(2),
514 try builder.nest(
515 4,
516 try builder.styledWords(.name, usage_operations),
517 ),
518 pretty.hardline,
519 try builder.spaces(2),
520 try builder.nest(
521 4,
522 try builder.styledWords(.name, usage_operation),
523 ),
524 pretty.hardline,
525 try builder.spaces(2),
526 try builder.nest(
527 4,
528 try builder.styledWords(.name, usage_roots),
529 ),
530 pretty.hardline,
531 try builder.spaces(2),
532 try builder.nest(
533 4,
534 try builder.styledWords(.name, usage_budget),
535 ),
536 pretty.hardline,
537 });
538 }
539
540 test "allocation args require positive report limits" {
541 const parsed = try parseAllocationArgs(&.{
542 "events.jsonl",
543 "--binary",
544 "events.jsonl.exe",
545 "--top",
546 "12",
547 "--frames",
548 "24",
549 "--format",
550 "jsonl",
551 "--layer",
552 "logical",
553 "--scope",
554 "root/fixture.phase.first",
555 "--from-seq",
556 "10928",
557 "--to-seq",
558 "299873",
559 });
560 try std.testing.expectEqualStrings("events.jsonl", parsed.path);
561 try std.testing.expectEqualStrings(
562 "events.jsonl.exe",
563 parsed.options.binary_path.?,
564 );
565 try std.testing.expectEqual(@as(usize, 12), parsed.options.top);
566 try std.testing.expectEqual(@as(usize, 24), parsed.options.frame_limit);
567 try std.testing.expectEqual(stack.report.Format.jsonl, parsed.options.format);
568 try std.testing.expectEqual(event.LayerFilter.logical, parsed.options.layer);
569 try std.testing.expectEqualStrings(
570 "root/fixture.phase.first",
571 parsed.options.window.scope.?,
572 );
573 try std.testing.expectEqual(
574 @as(?u64, 10928),
575 parsed.options.window.first_sequence,
576 );
577 try std.testing.expectEqual(
578 @as(?u64, 299873),
579 parsed.options.window.last_sequence,
580 );
581 }
582
583 test "summary args parse format and filters" {
584 const parsed = try parseSummaryArgs(&.{
585 "events.jsonl",
586 "--format",
587 "jsonl",
588 "--layer",
589 "all",
590 "--sort",
591 "traffic",
592 "--top",
593 "8",
594 "--min-bytes",
595 "32",
596 "--include-zero-live",
597 "--include-sites",
598 "--site-detail",
599 "0xabc",
600 "--symbolize",
601 "zig-out/bin/tiny",
602 });
603 try std.testing.expectEqualStrings("events.jsonl", parsed.path);
604 try std.testing.expectEqual(Format.jsonl, parsed.format);
605 try std.testing.expectEqual(event.LayerFilter.all, parsed.options.layer);
606 try std.testing.expectEqual(analysis.Sort.traffic, parsed.options.sort);
607 try std.testing.expectEqual(@as(usize, 8), parsed.options.top);
608 try std.testing.expectEqual(@as(usize, 32), parsed.options.min_bytes);
609 try std.testing.expect(parsed.options.include_zero_live);
610 try std.testing.expect(parsed.options.include_sites);
611 try std.testing.expectEqual(@as(u64, 0xabc), parsed.options.site_detail_return_address.?);
612 try std.testing.expectEqualStrings("zig-out/bin/tiny", parsed.options.site_symbol_binary.?);
613 }
614
615 test "operation args parse exact identifier and symbol options" {
616 const parsed = try parseOperationArgs(&.{
617 "events.jsonl",
618 "42",
619 "--binary",
620 "events.jsonl.exe",
621 "--frames",
622 "24",
623 "--format",
624 "jsonl",
625 });
626 try std.testing.expectEqualStrings("events.jsonl", parsed.path);
627 try std.testing.expectEqual(@as(u64, 42), parsed.operation_id);
628 try std.testing.expectEqualStrings(
629 "events.jsonl.exe",
630 parsed.options.binary_path.?,
631 );
632 try std.testing.expectEqual(@as(usize, 24), parsed.options.frame_limit);
633 try std.testing.expectEqual(causal.Format.jsonl, parsed.options.format);
634 }
635
636 test "causal root args parse selection and symbol options" {
637 const parsed = try parseRootArgs(&.{
638 "events.jsonl",
639 "--binary",
640 "events.jsonl.exe",
641 "--top",
642 "12",
643 "--frames",
644 "24",
645 "--format",
646 "jsonl",
647 "--selection",
648 "all",
649 "--sort",
650 "backing-bytes",
651 "--detail",
652 "sources",
653 "--scope",
654 "root/fixture.phase.repeat",
655 "--from-seq",
656 "299874",
657 "--to-seq",
658 "438296",
659 });
660 try std.testing.expectEqualStrings("events.jsonl", parsed.path);
661 try std.testing.expectEqualStrings(
662 "events.jsonl.exe",
663 parsed.options.binary_path.?,
664 );
665 try std.testing.expectEqual(@as(usize, 12), parsed.options.top);
666 try std.testing.expectEqual(@as(usize, 24), parsed.options.frame_limit);
667 try std.testing.expectEqual(stack.roots.Format.jsonl, parsed.options.format);
668 try std.testing.expectEqual(
669 stack.report.Selection.all,
670 parsed.options.selection,
671 );
672 try std.testing.expectEqual(stack.roots.Sort.backing_bytes, parsed.options.sort);
673 try std.testing.expectEqual(stack.roots.Detail.sources, parsed.options.detail);
674 try std.testing.expectEqualStrings(
675 "root/fixture.phase.repeat",
676 parsed.options.window.scope.?,
677 );
678 try std.testing.expectEqual(
679 @as(?u64, 299874),
680 parsed.options.window.first_sequence,
681 );
682 try std.testing.expectEqual(
683 @as(?u64, 438296),
684 parsed.options.window.last_sequence,
685 );
686 }
687
688 test "allocation and root args reject inverted sequence windows" {
689 try std.testing.expectError(
690 error.InvalidSequenceWindow,
691 parseAllocationArgs(&.{
692 "events.jsonl",
693 "--from-seq",
694 "8",
695 "--to-seq",
696 "7",
697 }),
698 );
699 try std.testing.expectError(
700 error.InvalidSequenceWindow,
701 parseRootArgs(&.{
702 "events.jsonl",
703 "--from-seq",
704 "8",
705 "--to-seq",
706 "7",
707 }),
708 );
709 }
710
711 test "causal budget args parse report budget and format" {
712 const parsed = try parseBudgetArgs(&.{
713 "roots.jsonl",
714 "budget.json",
715 "--format",
716 "jsonl",
717 });
718 try std.testing.expectEqualStrings("roots.jsonl", parsed.report_path);
719 try std.testing.expectEqualStrings("budget.json", parsed.budget_path);
720 try std.testing.expectEqual(stack.budget.Format.jsonl, parsed.format);
721 }
722
723 test "usage renders through pretty plain and colored output" {
724 const plain = try renderUsageAlloc(std.testing.allocator, .{ .width = 88 });
725 defer std.testing.allocator.free(plain);
726 try std.testing.expect(std.mem.indexOf(u8, plain, "\x1b[") == null);
727 try std.testing.expect(std.mem.indexOf(u8, plain, "usage:\n") != null);
728 try std.testing.expect(std.mem.indexOf(u8, plain, "memtrace summary <events.jsonl>") != null);
729 try std.testing.expect(std.mem.indexOf(
730 u8,
731 plain,
732 "memtrace allocations <events.jsonl>",
733 ) != null);
734 try std.testing.expect(std.mem.indexOf(
735 u8,
736 plain,
737 "memtrace roots <events.jsonl>",
738 ) != null);
739 try std.testing.expect(std.mem.indexOf(
740 u8,
741 plain,
742 "memtrace budget <roots.jsonl>",
743 ) != null);
744
745 const colored = try renderUsageAlloc(std.testing.allocator, .{ .width = 88, .color = .ansi });
746 defer std.testing.allocator.free(colored);
747 try std.testing.expect(std.mem.indexOf(u8, colored, "\x1b[") != null);
748 }
749
750 test "usage wraps long memtrace command at terminal width" {
751 const rendered = try renderUsageAlloc(std.testing.allocator, .{ .width = 48 });
752 defer std.testing.allocator.free(rendered);
753 try std.testing.expect(std.mem.indexOf(
754 u8,
755 rendered,
756 "memtrace summary <events.jsonl> [--sort\n",
757 ) != null);
758 }
759
760 test "errors render through pretty plain and colored output" {
761 const plain = try pretty_usage.renderErrorTextAlloc(
762 std.testing.allocator,
763 "memtrace",
764 "UnknownCommand",
765 .{ .layout = .{ .width = 88 } },
766 );
767 defer std.testing.allocator.free(plain);
768 try std.testing.expectEqualStrings("memtrace: UnknownCommand\n", plain);
769
770 const colored = try pretty_usage.renderErrorTextAlloc(
771 std.testing.allocator,
772 "memtrace",
773 "UnknownCommand",
774 .{ .layout = .{ .width = 88, .color = .ansi } },
775 );
776 defer std.testing.allocator.free(colored);
777 try std.testing.expect(std.mem.indexOf(u8, colored, "\x1b[") != null);
778 try std.testing.expect(std.mem.indexOf(u8, colored, "UnknownCommand") != null);
779 }