lib/pluck/src/cli/app.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 pub const exit_codes = @import("codes.zig");
  2 
  3 pub const ExitCode = exit_codes.ExitCode;
  4 
  5 const std = @import("std");
  6 const sys = @import("sys");
  7 const build_options = @import("build_options");
  8 const pluck = @import("../root.zig");
  9 const pretty = @import("pretty");
 10 const pretty_usage = @import("pretty_usage");
 11 const evaluator = pluck.evaluator;
 12 const help = pluck.help_topics;
 13 const def_order = pluck.definition_order;
 14 
 15 const Allocator = std.mem.Allocator;
 16 
 17 pub const DEFAULT_MAX_DEPTH: ?u32 = null;
 18 
 19 pub const DEFAULT_ITE_LIMIT: ?u64 = pluck.limits.default_agent_ite_limit;
 20 
 21 pub const version = build_options.version;
 22 
 23 const ParseValueResult = union(enum) {
 24     ok,
 25     unknown_option,
 26     invalid_value: []const u8,
 27 };
 28 
 29 const expected_time_limit = "a finite non-negative number of seconds (for example: 0.25 or 10)";
 30 const expected_non_negative_integer = "a non-negative integer";
 31 const expected_limit_integer = "a non-negative integer (0 means unlimited)";
 32 const expected_positive_integer = "an integer greater than 0";
 33 const expected_fallback_mode = "one of: error, lpsmc";
 34 const expected_var_order = "one of: strict, reverse, creation, topo, min-fill, auto";
 35 
 36 const usage_text =
 37     \\usage: pluck [OPTIONS] [FILES...]
 38     \\  pluck -e EXPR [OPTIONS]
 39     \\  pluck --help [topic]
 40 ;
 41 
 42 const help_text =
 43     \\Run probabilistic programs and evaluate one-off queries.
 44     \\
 45     \\usage: pluck [OPTIONS] [FILES...]
 46     \\  pluck -e EXPR [OPTIONS]
 47     \\  pluck --help [topic]
 48     \\
 49     \\Common workflows:
 50     \\  pluck model.pluck
 51     \\  pluck -e "(Marginal (flip 0.5))"
 52     \\  echo "(Marginal (flip 0.3))" | pluck -
 53     \\  pluck --json model.pluck
 54     \\  pluck --help topics
 55     \\  pluck --help syntax-overview
 56     \\  pluck --help Marginal
 57     \\
 58     \\Options:
 59     \\  Help and metadata:
 60     \\    -h, --help [topic]           Show general help or a specific topic
 61     \\    --version                    Show CLI version
 62     \\
 63     \\  Input and output:
 64     \\    -e, --eval EXPR              Evaluate one expression and exit
 65     \\    -j, --json                   Emit JSON Lines output for scripts/agents
 66     \\    -q, --silent                 Suppress non-essential output
 67     \\    -v, --verbose                Print file loading and execution progress
 68     \\
 69     \\  Execution controls:
 70     \\    --time-limit=SECS            Inference time limit in seconds (default: unlimited)
 71     \\    --max-depth=N                Recursion depth limit (default: unlimited, 0=unlimited)
 72     \\    --ite-limit=N                BDD work quota (default: 10000000, 0=unlimited)
 73     \\    --sample-after-max-depth     Sample flips beyond max-depth instead of erroring
 74     \\    --seed=N                     RNG seed for sampling queries (default: random)
 75     \\    -p, --parallel               Enable parallel weighted model counting
 76     \\
 77     \\  Inference fallback and ordering:
 78     \\    --fallback-mode=MODE         Unsupported exact inference: error|lpsmc (default: error)
 79     \\    --fallback-k=N               Top-k paths for lpsmc fallback (default: 10, >0)
 80     \\    --lpsmc-adaptive-k           Enable adaptive per-subproblem k
 81     \\    --lpsmc-adaptive-k-max=N     Adaptive-k upper bound (default: 64, >0)
 82     \\    --lpsmc-workers=N            Parallel workers for lpsmc (default: 1, >0)
 83     \\    --var-order=MODE             strict|reverse|creation|topo|min-fill|auto (default: strict)
 84     \\    --factor-max-branches=N      Branch cap before WeightDD fallback (default: 64)
 85     \\    --weight-dd-max-nodes=N      WeightDD node cap (default: 0, 0=unlimited)
 86     \\
 87     \\Exit Codes:
 88     \\  0  Success
 89     \\  1  Parse/runtime/IO/help error
 90     \\  2  Partial results due to resource limits
 91     \\
 92     \\Need a topic index?
 93     \\  pluck --help topics
 94 ;
 95 
 96 const parse_error_tail_text =
 97     \\usage: pluck [OPTIONS] [FILES...]
 98     \\  pluck -e EXPR [OPTIONS]
 99     \\  pluck --help [topic]
100     \\
101     \\Run `pluck --help` for the full option reference.
102     \\Run `pluck --help topics` to browse language/query help topics.
103     \\
104     \\Common examples:
105     \\  pluck model.pluck
106     \\  pluck -e "(Marginal (flip 0.5))"
107     \\  pluck --json model.pluck
108 ;
109 
110 const unknown_help_tail_text =
111     \\Try one of these first:
112     \\  pluck --help topics
113     \\  pluck --help syntax-overview
114     \\  pluck --help Marginal
115     \\  pluck --help flip
116     \\
117 ;
118 
119 pub const CLIOptions = struct {
120     files: std.ArrayList([]const u8),
121     time_limit: ?f64 = null,
122     max_depth: ?u32 = DEFAULT_MAX_DEPTH,
123     ite_limit: ?u64 = DEFAULT_ITE_LIMIT,
124     sample_after_max_depth: bool = false,
125     verbose: bool = false,
126     help: bool = false,
127     help_topic: ?[]const u8 = null,
128     version: bool = false,
129     parse_error: bool = false,
130     parse_error_message: ?[]const u8 = null,
131     parallel: bool = false,
132     json: bool = false,
133     silent: bool = false,
134     eval_expr: ?[]const u8 = null,
135     rng_seed: ?u64 = null,
136     fallback_mode: evaluator.FallbackMode = .@"error",
137     fallback_lpsmc_k: usize = 10,
138     lpsmc_adaptive_k: bool = false,
139     lpsmc_adaptive_k_max: usize = 64,
140     lpsmc_workers: usize = 1,
141     factor_max_branches: usize = 64,
142     weight_dd_max_nodes: usize = 0,
143     use_strict_order: bool = true,
144     use_reverse_order: bool = false,
145     var_order_fallback: bool = false,
146     definition_order_mode: def_order.DefinitionOrderMode = .none,
147 };
148 
149 pub fn parseArgs(allocator: Allocator, process_args: sys.process.Args) !CLIOptions {
150     var args_arena = std.heap.ArenaAllocator.init(allocator);
151     defer args_arena.deinit();
152 
153     const args_z = try process_args.toSlice(args_arena.allocator());
154 
155     var args_list: std.ArrayList([]const u8) = .empty;
156     defer args_list.deinit(allocator);
157     for (args_z) |arg_z| {
158         try args_list.append(allocator, arg_z[0..arg_z.len]);
159     }
160     const args = try args_list.toOwnedSlice(allocator);
161     defer allocator.free(args);
162 
163     const all_args = if (args.len > 0) args[1..] else args;
164 
165     return parseArgsFromSlice(allocator, all_args);
166 }
167 
168 pub fn parseArgsFromSlice(allocator: Allocator, all_args: []const []const u8) !CLIOptions {
169     var opts = CLIOptions{
170         .files = .empty,
171     };
172     errdefer {
173         for (opts.files.items) |f| {
174             allocator.free(f);
175         }
176         opts.files.deinit(allocator);
177         if (opts.eval_expr) |e| allocator.free(e);
178         if (opts.help_topic) |topic| allocator.free(topic);
179         if (opts.parse_error_message) |message| allocator.free(message);
180     }
181 
182     var i: usize = 0;
183     var seen_separator = false;
184     while (i < all_args.len) : (i += 1) {
185         const arg = all_args[i];
186 
187         if (std.mem.eql(u8, arg, "--")) {
188             seen_separator = true;
189             continue;
190         }
191 
192         if (seen_separator) {
193             const duped = try allocator.dupe(u8, arg);
194             try opts.files.append(allocator, duped);
195             continue;
196         }
197 
198         if (arg.len > 2 and arg[0] == '-' and arg[1] == '-') {
199             const rest = arg[2..];
200 
201             if (std.mem.indexOf(u8, rest, "=")) |eq_pos| {
202                 const key = rest[0..eq_pos];
203                 const val = rest[eq_pos + 1 ..];
204                 const result = try applyLongWithValue(&opts, allocator, key, val);
205                 switch (result) {
206                     .ok => {},
207                     .unknown_option => {
208                         setUnknownLongOptionError(&opts, allocator, key);
209                         return opts;
210                     },
211                     .invalid_value => |expected| {
212                         setInvalidLongValueError(&opts, allocator, key, val, expected);
213                         return opts;
214                     },
215                 }
216             } else {
217                 if (std.mem.eql(u8, rest, "help")) {
218                     opts.help = true;
219                     try maybeConsumeHelpTopic(&opts, allocator, all_args, &i);
220                 } else if (std.mem.eql(u8, rest, "version")) {
221                     opts.version = true;
222                 } else if (std.mem.eql(u8, rest, "verbose")) {
223                     opts.verbose = true;
224                 } else if (std.mem.eql(u8, rest, "json")) {
225                     opts.json = true;
226                 } else if (std.mem.eql(u8, rest, "silent")) {
227                     opts.silent = true;
228                 } else if (std.mem.eql(u8, rest, "parallel")) {
229                     opts.parallel = true;
230                 } else if (std.mem.eql(u8, rest, "sample-after-max-depth")) {
231                     opts.sample_after_max_depth = true;
232                 } else if (std.mem.eql(u8, rest, "lpsmc-adaptive-k")) {
233                     opts.lpsmc_adaptive_k = true;
234                 } else if (longNeedsValue(rest)) {
235                     if (i + 1 < all_args.len) {
236                         i += 1;
237                         const val = all_args[i];
238                         const result = try applyLongWithValue(&opts, allocator, rest, val);
239                         switch (result) {
240                             .ok => {},
241                             .unknown_option => {
242                                 setUnknownLongOptionError(&opts, allocator, rest);
243                                 return opts;
244                             },
245                             .invalid_value => |expected| {
246                                 setInvalidLongValueError(&opts, allocator, rest, val, expected);
247                                 return opts;
248                             },
249                         }
250                     } else {
251                         setMissingLongValueError(&opts, allocator, rest);
252                         return opts;
253                     }
254                 } else {
255                     setUnknownLongOptionError(&opts, allocator, rest);
256                     return opts;
257                 }
258             }
259             continue;
260         }
261 
262         if (arg.len > 1 and arg[0] == '-') {
263             if (arg.len == 1) {
264                 const duped = try allocator.dupe(u8, arg);
265                 try opts.files.append(allocator, duped);
266                 continue;
267             }
268 
269             const ch = arg[1];
270             switch (ch) {
271                 'h' => {
272                     opts.help = true;
273                     try maybeConsumeHelpTopic(&opts, allocator, all_args, &i);
274                 },
275                 'v' => opts.verbose = true,
276                 'j' => opts.json = true,
277                 'q' => opts.silent = true,
278                 'p' => opts.parallel = true,
279                 'e' => {
280                     const val = if (arg.len > 2) arg[2..] else blk: {
281                         if (i + 1 < all_args.len) {
282                             i += 1;
283                             break :blk all_args[i];
284                         } else {
285                             setMissingShortValueError(&opts, allocator, 'e', "pluck -e \"(Marginal (flip 0.5))\"");
286                             return opts;
287                         }
288                     };
289                     opts.eval_expr = try allocator.dupe(u8, val);
290                 },
291                 else => {
292                     setUnknownShortOptionError(&opts, allocator, ch);
293                     return opts;
294                 },
295             }
296             continue;
297         }
298 
299         const duped = try allocator.dupe(u8, arg);
300         try opts.files.append(allocator, duped);
301     }
302 
303     return opts;
304 }
305 
306 fn setParseError(opts: *CLIOptions, allocator: Allocator, comptime fmt: []const u8, args: anytype) void {
307     opts.parse_error = true;
308     opts.help = true;
309     if (opts.parse_error_message != null) return;
310     opts.parse_error_message = std.fmt.allocPrint(allocator, fmt, args) catch null;
311 }
312 
313 fn setUnknownLongOptionError(opts: *CLIOptions, allocator: Allocator, name: []const u8) void {
314     setParseError(
315         opts,
316         allocator,
317         "error: unknown option '--{s}'\nHint: run `pluck --help` to list supported options.",
318         .{name},
319     );
320 }
321 
322 fn setUnknownShortOptionError(opts: *CLIOptions, allocator: Allocator, short_opt: u8) void {
323     setParseError(
324         opts,
325         allocator,
326         "error: unknown option '-{c}'\nHint: run `pluck --help` to list supported options.",
327         .{short_opt},
328     );
329 }
330 
331 fn setMissingLongValueError(opts: *CLIOptions, allocator: Allocator, name: []const u8) void {
332     setParseError(
333         opts,
334         allocator,
335         "error: option '--{s}' requires a value\nExample: {s}",
336         .{ name, exampleForLongOption(name) },
337     );
338 }
339 
340 fn setMissingShortValueError(opts: *CLIOptions, allocator: Allocator, short_opt: u8, example: []const u8) void {
341     setParseError(
342         opts,
343         allocator,
344         "error: option '-{c}' requires a value\nExample: {s}",
345         .{ short_opt, example },
346     );
347 }
348 
349 fn setInvalidLongValueError(opts: *CLIOptions, allocator: Allocator, name: []const u8, value: []const u8, expected: []const u8) void {
350     setParseError(
351         opts,
352         allocator,
353         "error: invalid value '{s}' for '--{s}'\nExpected: {s}",
354         .{ value, name, expected },
355     );
356 }
357 
358 fn exampleForLongOption(name: []const u8) []const u8 {
359     if (std.mem.eql(u8, name, "eval")) return "pluck --eval \"(Marginal (flip 0.5))\"";
360     if (std.mem.eql(u8, name, "time-limit")) return "pluck --time-limit=1.5 model.pluck";
361     if (std.mem.eql(u8, name, "max-depth")) return "pluck --max-depth=500 model.pluck";
362     if (std.mem.eql(u8, name, "ite-limit")) return "pluck --ite-limit=500000 model.pluck";
363     if (std.mem.eql(u8, name, "seed")) return "pluck --seed=42 model.pluck";
364     if (std.mem.eql(u8, name, "fallback-mode")) return "pluck --fallback-mode=lpsmc model.pluck";
365     if (std.mem.eql(u8, name, "fallback-k")) return "pluck --fallback-k=20 model.pluck";
366     if (std.mem.eql(u8, name, "lpsmc-adaptive-k-max")) return "pluck --lpsmc-adaptive-k --lpsmc-adaptive-k-max=128 model.pluck";
367     if (std.mem.eql(u8, name, "lpsmc-workers")) return "pluck --lpsmc-workers=4 model.pluck";
368     if (std.mem.eql(u8, name, "factor-max-branches")) return "pluck --factor-max-branches=128 model.pluck";
369     if (std.mem.eql(u8, name, "weight-dd-max-nodes")) return "pluck --weight-dd-max-nodes=200000 model.pluck";
370     if (std.mem.eql(u8, name, "var-order")) return "pluck --var-order=min-fill model.pluck";
371     return "pluck --help";
372 }
373 
374 fn maybeConsumeHelpTopic(opts: *CLIOptions, allocator: Allocator, all_args: []const []const u8, index: *usize) !void {
375     if (opts.help_topic != null) return;
376     if (index.* + 1 >= all_args.len) return;
377     const next = all_args[index.* + 1];
378     if (!isHelpTopicCandidate(next)) return;
379     index.* += 1;
380     opts.help_topic = try allocator.dupe(u8, next);
381 }
382 
383 fn isHelpTopicCandidate(arg: []const u8) bool {
384     if (arg.len == 0) return false;
385     return arg[0] != '-';
386 }
387 
388 fn longNeedsValue(name: []const u8) bool {
389     const value_opts = [_][]const u8{
390         "eval",
391         "time-limit",
392         "max-depth",
393         "ite-limit",
394         "seed",
395         "fallback-mode",
396         "fallback-k",
397         "lpsmc-adaptive-k-max",
398         "lpsmc-workers",
399         "factor-max-branches",
400         "weight-dd-max-nodes",
401         "var-order",
402     };
403     for (&value_opts) |opt| {
404         if (std.mem.eql(u8, name, opt)) return true;
405     }
406     return false;
407 }
408 
409 fn applyLongWithValue(opts: *CLIOptions, allocator: Allocator, key: []const u8, val: []const u8) !ParseValueResult {
410     if (std.mem.eql(u8, key, "help")) {
411         opts.help = true;
412         opts.help_topic = try allocator.dupe(u8, val);
413     } else if (std.mem.eql(u8, key, "eval")) {
414         opts.eval_expr = try allocator.dupe(u8, val);
415     } else if (std.mem.eql(u8, key, "time-limit")) {
416         const secs = std.fmt.parseFloat(f64, val) catch return .{ .invalid_value = expected_time_limit };
417         if (!std.math.isFinite(secs) or secs < 0) return .{ .invalid_value = expected_time_limit };
418         opts.time_limit = secs;
419     } else if (std.mem.eql(u8, key, "max-depth")) {
420         const md = std.fmt.parseInt(u32, val, 10) catch return .{ .invalid_value = expected_limit_integer };
421         opts.max_depth = if (md == 0) null else md;
422     } else if (std.mem.eql(u8, key, "ite-limit")) {
423         const il = std.fmt.parseInt(u64, val, 10) catch return .{ .invalid_value = expected_limit_integer };
424         opts.ite_limit = if (il == 0) null else il;
425     } else if (std.mem.eql(u8, key, "seed")) {
426         opts.rng_seed = std.fmt.parseInt(u64, val, 10) catch return .{ .invalid_value = expected_non_negative_integer };
427     } else if (std.mem.eql(u8, key, "fallback-mode")) {
428         if (std.mem.eql(u8, val, "error")) {
429             opts.fallback_mode = .@"error";
430         } else if (std.mem.eql(u8, val, "lpsmc")) {
431             opts.fallback_mode = .lpsmc;
432         } else {
433             return .{ .invalid_value = expected_fallback_mode };
434         }
435     } else if (std.mem.eql(u8, key, "fallback-k")) {
436         const k = std.fmt.parseInt(usize, val, 10) catch return .{ .invalid_value = expected_positive_integer };
437         if (k == 0) return .{ .invalid_value = expected_positive_integer };
438         opts.fallback_lpsmc_k = k;
439     } else if (std.mem.eql(u8, key, "lpsmc-adaptive-k-max")) {
440         const k = std.fmt.parseInt(usize, val, 10) catch return .{ .invalid_value = expected_positive_integer };
441         if (k == 0) return .{ .invalid_value = expected_positive_integer };
442         opts.lpsmc_adaptive_k_max = k;
443     } else if (std.mem.eql(u8, key, "lpsmc-workers")) {
444         const workers = std.fmt.parseInt(usize, val, 10) catch return .{ .invalid_value = expected_positive_integer };
445         if (workers == 0) return .{ .invalid_value = expected_positive_integer };
446         opts.lpsmc_workers = workers;
447     } else if (std.mem.eql(u8, key, "factor-max-branches")) {
448         opts.factor_max_branches = std.fmt.parseInt(usize, val, 10) catch return .{ .invalid_value = expected_non_negative_integer };
449     } else if (std.mem.eql(u8, key, "weight-dd-max-nodes")) {
450         opts.weight_dd_max_nodes = std.fmt.parseInt(usize, val, 10) catch return .{ .invalid_value = expected_non_negative_integer };
451     } else if (std.mem.eql(u8, key, "var-order")) {
452         if (std.mem.eql(u8, val, "strict") or std.mem.eql(u8, val, "callstack")) {
453             opts.use_strict_order = true;
454             opts.use_reverse_order = false;
455             opts.var_order_fallback = false;
456             opts.definition_order_mode = .none;
457         } else if (std.mem.eql(u8, val, "reverse") or std.mem.eql(u8, val, "callstack-reverse")) {
458             opts.use_strict_order = true;
459             opts.use_reverse_order = true;
460             opts.var_order_fallback = false;
461             opts.definition_order_mode = .none;
462         } else if (std.mem.eql(u8, val, "creation")) {
463             opts.use_strict_order = false;
464             opts.use_reverse_order = false;
465             opts.var_order_fallback = false;
466             opts.definition_order_mode = .none;
467         } else if (std.mem.eql(u8, val, "topo") or std.mem.eql(u8, val, "topological")) {
468             opts.use_strict_order = true;
469             opts.use_reverse_order = false;
470             opts.var_order_fallback = false;
471             opts.definition_order_mode = .topological;
472         } else if (std.mem.eql(u8, val, "min-fill") or std.mem.eql(u8, val, "minfill") or std.mem.eql(u8, val, "min-degree")) {
473             opts.use_strict_order = true;
474             opts.use_reverse_order = false;
475             opts.var_order_fallback = false;
476             opts.definition_order_mode = .min_fill;
477         } else if (std.mem.eql(u8, val, "auto")) {
478             opts.use_strict_order = true;
479             opts.use_reverse_order = false;
480             opts.var_order_fallback = true;
481             opts.definition_order_mode = .none;
482         } else {
483             return .{ .invalid_value = expected_var_order };
484         }
485     } else {
486         return .unknown_option;
487     }
488     return .ok;
489 }
490 
491 pub fn freeOptions(allocator: Allocator, opts: *CLIOptions) void {
492     for (opts.files.items) |f| {
493         allocator.free(f);
494     }
495     opts.files.deinit(allocator);
496     if (opts.eval_expr) |e| allocator.free(e);
497     if (opts.help_topic) |topic| allocator.free(topic);
498     if (opts.parse_error_message) |message| allocator.free(message);
499 }
500 
501 pub fn printUsage(allocator: Allocator, writer: *std.Io.Writer, options: pretty.LayoutOptions) !void {
502     try pretty_usage.writeUsageText(allocator, writer, usage_text, .{ .layout = options });
503 }
504 
505 pub fn printHelp(allocator: Allocator, writer: *std.Io.Writer, options: pretty.LayoutOptions) !void {
506     try writePrettyFmt(allocator, writer, options, "pluck {s} - Probabilistic Programming in Zig\n\n", .{version});
507     try pretty_usage.writeUsageText(allocator, writer, help_text, .{ .layout = options });
508 }
509 
510 pub fn printHelpTopic(allocator: Allocator, writer: *std.Io.Writer, topic: []const u8, options: pretty.LayoutOptions) !bool {
511     if (std.mem.eql(u8, topic, "topics")) {
512         try printHelpTopicsOverview(allocator, writer, options);
513         return true;
514     }
515     if (help.getHelpTopic(topic)) |help_topic| {
516         var text = std.Io.Writer.Allocating.init(allocator);
517         defer text.deinit();
518         try text.writer.print("{s}\n", .{help_topic.title});
519         for (help_topic.lines) |line| {
520             try text.writer.print("{s}\n", .{line});
521         }
522         try pretty_usage.writeUsageText(allocator, writer, text.writer.buffered(), .{ .layout = options });
523         return true;
524     }
525     return false;
526 }
527 
528 pub fn printHelpTopicsOverview(allocator: Allocator, writer: *std.Io.Writer, options: pretty.LayoutOptions) !void {
529     var text = std.Io.Writer.Allocating.init(allocator);
530     defer text.deinit();
531 
532     try text.writer.writeAll("Available help topics by category:\n");
533     try text.writer.writeAll("  primitives:  ");
534     try writeTopicList(&text.writer, &help.help_topics.primitives);
535     try text.writer.writeAll("  queries:     ");
536     try writeTopicList(&text.writer, &help.help_topics.queries);
537     try text.writer.writeAll("  syntax:      ");
538     try writeTopicList(&text.writer, &help.help_topics.syntax);
539     try text.writer.writeAll("  types:       ");
540     try writeTopicList(&text.writer, &help.help_topics.types);
541     try text.writer.writeAll("  stdlib:      ");
542     try writeTopicList(&text.writer, &help.help_topics.stdlib);
543     try text.writer.writeAll("  commands:    ");
544     try writeTopicList(&text.writer, &help.help_topics.commands);
545     try text.writer.writeAll("Use `pluck --help <topic>` to view details.\n");
546 
547     try pretty_usage.writeUsageText(allocator, writer, text.writer.buffered(), .{ .layout = options });
548 }
549 
550 pub fn printParseError(allocator: Allocator, writer: *std.Io.Writer, opts: *const CLIOptions, options: pretty.LayoutOptions) !void {
551     if (opts.parse_error_message) |message| {
552         try writePrettyFmt(allocator, writer, options, "{s}\n\n", .{message});
553     } else {
554         try writePrettyText(writer, options, "error: invalid command-line arguments\n\n");
555     }
556     try pretty_usage.writeUsageText(allocator, writer, parse_error_tail_text, .{ .layout = options });
557 }
558 
559 pub fn printUnknownHelpTopic(allocator: Allocator, writer: *std.Io.Writer, topic: []const u8, options: pretty.LayoutOptions) !void {
560     try writePrettyFmt(allocator, writer, options, "error: unknown help topic '{s}'\n\n", .{topic});
561     try pretty_usage.writeUsageText(allocator, writer, unknown_help_tail_text, .{ .layout = options });
562     try printHelpTopicsOverview(allocator, writer, options);
563 }
564 
565 fn writeTopicList(writer: *std.Io.Writer, topics: []const []const u8) !void {
566     for (topics, 0..) |topic, i| {
567         if (i > 0) try writer.writeAll(", ");
568         try writer.writeAll(topic);
569     }
570     try writer.writeAll("\n");
571 }
572 
573 pub fn prettyOptions(file: std.Io.File) pretty.LayoutOptions {
574     return pretty_usage.layoutOptions(file, .{});
575 }
576 
577 fn writePrettyFmt(allocator: Allocator, writer: *std.Io.Writer, options: pretty.LayoutOptions, comptime fmt: []const u8, args: anytype) !void {
578     const text = try std.fmt.allocPrint(allocator, fmt, args);
579     defer allocator.free(text);
580     try writePrettyText(writer, options, text);
581 }
582 
583 fn writePrettyText(writer: *std.Io.Writer, options: pretty.LayoutOptions, text: []const u8) !void {
584     try pretty.write(writer, .{ .text = text }, options);
585 }
586 
587 pub fn isStdinTty() bool {
588     const stdin = sys.stdio.stdin();
589     return stdin.isTty(sys.stdio.debugIo()) catch false;
590 }
591 
592 fn expectParseErrorMessage(args: []const []const u8, expected: []const u8) !void {
593     const allocator = std.testing.allocator;
594     var opts = try parseArgsFromSlice(allocator, args);
595     defer freeOptions(allocator, &opts);
596 
597     try std.testing.expect(opts.parse_error);
598     try std.testing.expect(opts.help);
599     try std.testing.expect(opts.parse_error_message != null);
600     try std.testing.expectEqualStrings(expected, opts.parse_error_message.?);
601 }
602 
603 fn renderHelp(allocator: Allocator, options: pretty.LayoutOptions) ![]u8 {
604     var out = std.Io.Writer.Allocating.init(allocator);
605     errdefer out.deinit();
606     try printHelp(allocator, &out.writer, options);
607     return try out.toOwnedSlice();
608 }
609 
610 fn renderParseError(allocator: Allocator, opts: *const CLIOptions, options: pretty.LayoutOptions) ![]u8 {
611     var out = std.Io.Writer.Allocating.init(allocator);
612     errdefer out.deinit();
613     try printParseError(allocator, &out.writer, opts, options);
614     return try out.toOwnedSlice();
615 }
616 
617 test "CLI defaults to the agent BDD quota and zero clears it" {
618     const allocator = std.testing.allocator;
619     var defaults = try parseArgsFromSlice(allocator, &.{});
620     defer freeOptions(allocator, &defaults);
621     try std.testing.expectEqual(@as(?u64, pluck.limits.default_agent_ite_limit), defaults.ite_limit);
622 
623     var unlimited = try parseArgsFromSlice(allocator, &.{"--ite-limit=0"});
624     defer freeOptions(allocator, &unlimited);
625     try std.testing.expect(unlimited.ite_limit == null);
626 }
627 
628 test "parseArgsFromSlice captures help topic from --help value" {
629     const allocator = std.testing.allocator;
630     var opts = try parseArgsFromSlice(allocator, &[_][]const u8{ "--help", "discrete" });
631     defer freeOptions(allocator, &opts);
632 
633     try std.testing.expect(opts.help);
634     try std.testing.expect(opts.help_topic != null);
635     try std.testing.expectEqualStrings("discrete", opts.help_topic.?);
636     try std.testing.expectEqual(@as(usize, 0), opts.files.items.len);
637 }
638 
639 test "parseArgsFromSlice captures help topic from --help=topic" {
640     const allocator = std.testing.allocator;
641     var opts = try parseArgsFromSlice(allocator, &[_][]const u8{"--help=flip"});
642     defer freeOptions(allocator, &opts);
643 
644     try std.testing.expect(opts.help);
645     try std.testing.expect(opts.help_topic != null);
646     try std.testing.expectEqualStrings("flip", opts.help_topic.?);
647 }
648 
649 test "parseArgsFromSlice captures help topic from -h value" {
650     const allocator = std.testing.allocator;
651     var opts = try parseArgsFromSlice(allocator, &[_][]const u8{ "-h", "Posterior" });
652     defer freeOptions(allocator, &opts);
653 
654     try std.testing.expect(opts.help);
655     try std.testing.expect(opts.help_topic != null);
656     try std.testing.expectEqualStrings("Posterior", opts.help_topic.?);
657     try std.testing.expectEqual(@as(usize, 0), opts.files.items.len);
658 }
659 
660 test "parseArgsFromSlice leaves help topic null for bare --help" {
661     const allocator = std.testing.allocator;
662     var opts = try parseArgsFromSlice(allocator, &[_][]const u8{"--help"});
663     defer freeOptions(allocator, &opts);
664 
665     try std.testing.expect(opts.help);
666     try std.testing.expect(opts.help_topic == null);
667 }
668 
669 test "parseArgsFromSlice parses --var-order modes" {
670     const allocator = std.testing.allocator;
671 
672     var opts_reverse = try parseArgsFromSlice(allocator, &[_][]const u8{"--var-order=reverse"});
673     defer freeOptions(allocator, &opts_reverse);
674     try std.testing.expect(opts_reverse.use_strict_order);
675     try std.testing.expect(opts_reverse.use_reverse_order);
676     try std.testing.expectEqual(def_order.DefinitionOrderMode.none, opts_reverse.definition_order_mode);
677 
678     var opts_creation = try parseArgsFromSlice(allocator, &[_][]const u8{ "--var-order", "creation" });
679     defer freeOptions(allocator, &opts_creation);
680     try std.testing.expect(!opts_creation.use_strict_order);
681     try std.testing.expect(!opts_creation.use_reverse_order);
682     try std.testing.expectEqual(def_order.DefinitionOrderMode.none, opts_creation.definition_order_mode);
683 
684     var opts_topo = try parseArgsFromSlice(allocator, &[_][]const u8{"--var-order=topo"});
685     defer freeOptions(allocator, &opts_topo);
686     try std.testing.expect(opts_topo.use_strict_order);
687     try std.testing.expect(!opts_topo.use_reverse_order);
688     try std.testing.expectEqual(def_order.DefinitionOrderMode.topological, opts_topo.definition_order_mode);
689 
690     var opts_min_fill = try parseArgsFromSlice(allocator, &[_][]const u8{"--var-order=min-fill"});
691     defer freeOptions(allocator, &opts_min_fill);
692     try std.testing.expect(opts_min_fill.use_strict_order);
693     try std.testing.expect(!opts_min_fill.use_reverse_order);
694     try std.testing.expectEqual(def_order.DefinitionOrderMode.min_fill, opts_min_fill.definition_order_mode);
695 
696     var opts_auto = try parseArgsFromSlice(allocator, &[_][]const u8{"--var-order=auto"});
697     defer freeOptions(allocator, &opts_auto);
698     try std.testing.expect(opts_auto.use_strict_order);
699     try std.testing.expect(!opts_auto.use_reverse_order);
700     try std.testing.expect(opts_auto.var_order_fallback);
701     try std.testing.expectEqual(def_order.DefinitionOrderMode.none, opts_auto.definition_order_mode);
702 }
703 
704 test "parseArgsFromSlice treats arguments after -- as files" {
705     const allocator = std.testing.allocator;
706     var opts = try parseArgsFromSlice(allocator, &[_][]const u8{ "--json", "--", "--not-an-option", "-e", "file.pluck" });
707     defer freeOptions(allocator, &opts);
708 
709     try std.testing.expect(!opts.parse_error);
710     try std.testing.expect(opts.json);
711     try std.testing.expectEqual(@as(usize, 3), opts.files.items.len);
712     try std.testing.expectEqualStrings("--not-an-option", opts.files.items[0]);
713     try std.testing.expectEqualStrings("-e", opts.files.items[1]);
714     try std.testing.expectEqualStrings("file.pluck", opts.files.items[2]);
715 }
716 
717 test "parseArgsFromSlice reports unknown long option with hint" {
718     try expectParseErrorMessage(
719         &[_][]const u8{"--bogus"},
720         "error: unknown option '--bogus'\nHint: run `pluck --help` to list supported options.",
721     );
722 }
723 
724 test "parseArgsFromSlice reports unknown short option with hint" {
725     try expectParseErrorMessage(
726         &[_][]const u8{"-z"},
727         "error: unknown option '-z'\nHint: run `pluck --help` to list supported options.",
728     );
729 }
730 
731 test "parseArgsFromSlice reports missing long option value with example" {
732     try expectParseErrorMessage(
733         &[_][]const u8{"--eval"},
734         "error: option '--eval' requires a value\nExample: pluck --eval \"(Marginal (flip 0.5))\"",
735     );
736 }
737 
738 test "parseArgsFromSlice reports missing short option value with example" {
739     try expectParseErrorMessage(
740         &[_][]const u8{"-e"},
741         "error: option '-e' requires a value\nExample: pluck -e \"(Marginal (flip 0.5))\"",
742     );
743 }
744 
745 test "parseArgsFromSlice reports invalid numeric value" {
746     try expectParseErrorMessage(
747         &[_][]const u8{"--max-depth=oops"},
748         "error: invalid value 'oops' for '--max-depth'\nExpected: a non-negative integer (0 means unlimited)",
749     );
750 }
751 
752 test "parseArgsFromSlice reports invalid enum value" {
753     try expectParseErrorMessage(
754         &[_][]const u8{"--fallback-mode=approx"},
755         "error: invalid value 'approx' for '--fallback-mode'\nExpected: one of: error, lpsmc",
756     );
757 }
758 
759 test "printHelp renders through pretty usage layout" {
760     const allocator = std.testing.allocator;
761     const output = try renderHelp(allocator, .{ .width = 48 });
762     defer allocator.free(output);
763 
764     try std.testing.expect(std.mem.indexOf(u8, output, "pluck " ++ version ++ " - Probabilistic Programming in Zig") != null);
765     try std.testing.expect(std.mem.indexOf(u8, output, "usage: pluck [OPTIONS] [FILES...]") != null);
766     try std.testing.expect(std.mem.indexOf(u8, output, "Options:\n") != null);
767     try std.testing.expect(std.mem.indexOf(u8, output, "Show general\n                                 help or a\n                                 specific topic") != null);
768     try std.testing.expect(std.mem.indexOf(u8, output, "\x1b[") == null);
769 }
770 
771 test "printHelp supports pretty color roles" {
772     const allocator = std.testing.allocator;
773     const output = try renderHelp(allocator, .{ .width = 88, .color = .ansi });
774     defer allocator.free(output);
775 
776     try std.testing.expect(std.mem.indexOf(u8, output, "\x1b[") != null);
777     try std.testing.expect(std.mem.indexOf(u8, output, "pluck [OPTIONS]") != null);
778 }
779 
780 test "printParseError snapshot for invalid args" {
781     const allocator = std.testing.allocator;
782     var opts = try parseArgsFromSlice(allocator, &[_][]const u8{"--unknown-option"});
783     defer freeOptions(allocator, &opts);
784 
785     const output = try renderParseError(allocator, &opts, .{ .width = 88 });
786     defer allocator.free(output);
787 
788     try std.testing.expectEqualStrings(
789         \\error: unknown option '--unknown-option'
790         \\Hint: run `pluck --help` to list supported options.
791         \\
792         \\usage: pluck [OPTIONS] [FILES...]
793         \\  pluck -e EXPR [OPTIONS]
794         \\  pluck --help [topic]
795         \\
796         \\Run `pluck --help` for the full option reference. Run `pluck --help topics` to browse
797         \\language/query help topics.
798         \\
799         \\Common examples:
800         \\  pluck model.pluck
801         \\  pluck -e "(Marginal (flip 0.5))"
802         \\  pluck --json model.pluck
803         \\
804     ,
805         output,
806     );
807 }
808 
809 test "printHelpTopic supports topics overview alias" {
810     const allocator = std.testing.allocator;
811     var out_buffer = std.Io.Writer.Allocating.init(allocator);
812     defer out_buffer.deinit();
813 
814     try std.testing.expect(try printHelpTopic(allocator, &out_buffer.writer, "topics", .{ .width = 88 }));
815     const out = out_buffer.writer.buffered();
816     try std.testing.expect(std.mem.indexOf(u8, out, "Available help topics by category:") != null);
817     try std.testing.expect(std.mem.indexOf(u8, out, "primitives:") != null);
818     try std.testing.expect(std.mem.indexOf(u8, out, "queries:") != null);
819     try std.testing.expect(std.mem.indexOf(u8, out, "commands:") != null);
820 }
821 
822 test "printUnknownHelpTopic includes guidance and topic list" {
823     const allocator = std.testing.allocator;
824     var out_buffer = std.Io.Writer.Allocating.init(allocator);
825     defer out_buffer.deinit();
826 
827     try printUnknownHelpTopic(allocator, &out_buffer.writer, "does-not-exist", .{ .width = 88 });
828     const out = out_buffer.writer.buffered();
829     try std.testing.expect(std.mem.indexOf(u8, out, "error: unknown help topic 'does-not-exist'") != null);
830     try std.testing.expect(std.mem.indexOf(u8, out, "pluck --help topics") != null);
831     try std.testing.expect(std.mem.indexOf(u8, out, "Available help topics by category:") != null);
832 }