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 }