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 }