coding_tools/cli/ct_test.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! The `ct-test` command grammar (see [`crate::cli`]); the `ct-test` bin is a
5//! thin parse-and-dispatch wrapper over this `Cli`.
6
7use clap::Parser;
8
9use crate::explain::Format;
10use crate::pattern;
11use crate::pulse::HeartbeatOpts;
12
13#[derive(Parser, Debug)]
14#[command(
15 name = "ct-test",
16 version,
17 about = "Run a command as a framed experiment and emit a templated SUCCESS/ERROR verdict.",
18 long_about = "ct-test frames a command with the question it answers, classifies the result from \
19 what the command prints (not only its exit code), and emits a templated verdict \
20 (also reachable as `ct test`). The command is always launched directly — there is \
21 no shell mode. See `ct-test --explain` for agent-oriented documentation."
22)]
23pub struct Cli {
24 /// Question this experiment answers; printed as a "== ... ==" banner.
25 #[arg(long)]
26 pub question: Option<String>,
27
28 /// Program to run (must be on the fixed read-only allowlist).
29 #[arg(long)]
30 pub cmd: Option<String>,
31
32 /// Text written to the child's standard input. Accepts file:PATH / text:VALUE payloads.
33 #[arg(long)]
34 pub stdin: Option<String>,
35
36 /// Pin how matcher patterns are interpreted (promotion off): literal, glob, or regex.
37 #[arg(long, value_enum)]
38 pub mode: Option<pattern::Mode>,
39
40 /// Kill the command and classify ERROR if it runs longer than SECS seconds (fractional allowed); {CODE} becomes "timeout".
41 #[arg(long, value_name = "SECS")]
42 pub timeout: Option<f64>,
43
44 #[command(flatten)]
45 pub heartbeat: HeartbeatOpts,
46
47 /// Match in stdout OR stderr forces ERROR (synonym for the -stdout/-stderr pair).
48 #[arg(long)]
49 pub err_match: Option<String>,
50
51 /// Match in stdout forces ERROR.
52 #[arg(long)]
53 pub err_match_stdout: Option<String>,
54
55 /// Match in stderr forces ERROR.
56 #[arg(long)]
57 pub err_match_stderr: Option<String>,
58
59 /// Match in stdout OR stderr indicates SUCCESS (synonym for the -stdout/-stderr pair).
60 #[arg(long)]
61 pub ok_match: Option<String>,
62
63 /// Match in stdout indicates SUCCESS.
64 #[arg(long)]
65 pub ok_match_stdout: Option<String>,
66
67 /// Match in stderr indicates SUCCESS.
68 #[arg(long)]
69 pub ok_match_stderr: Option<String>,
70
71 /// Verdict when neither an --ok-match nor an --err-match matched: success, error, or exit (follow the exit code). Default: error if any --ok-match was given, else exit.
72 #[arg(long, value_enum)]
73 pub otherwise: Option<Otherwise>,
74
75 /// Distil captured output to lines matching this pattern (with --context around each), printed to stderr and available as {FOCUS}.
76 #[arg(long)]
77 pub focus: Option<String>,
78
79 /// Lines of context shown around each --focus match.
80 #[arg(long, default_value_t = 2)]
81 pub context: usize,
82
83 /// Keep only the last N lines of each captured stream in the {STDOUT}/{STDERR} emit tokens (matchers and --focus still see everything).
84 #[arg(long, value_name = "N")]
85 pub capture_tail: Option<usize>,
86
87 /// Template written to stdout after running. Tokens: {RESULT} {CODE} {QUESTION} {CMD} {STDOUT} {STDERR} {REASON} {FOCUS}.
88 #[arg(long, alias = "emit-stdout")]
89 pub emit: Option<String>,
90
91 /// Template written to stderr after running (same tokens as --emit).
92 #[arg(long)]
93 pub emit_stderr: Option<String>,
94
95 /// Also pass the child's stdout/stderr through verbatim.
96 #[arg(long)]
97 pub show_output: bool,
98
99 /// Suppress the question banner.
100 #[arg(long)]
101 pub quiet: bool,
102
103 /// Print agent usage docs (md or json) and exit.
104 #[arg(long, value_enum, num_args = 0..=1, default_missing_value = "md")]
105 pub explain: Option<Format>,
106
107 /// Arguments passed through to --cmd (after `--`).
108 #[arg(last = true)]
109 pub args: Vec<String>,
110}
111
112/// What an *inconclusive* run resolves to — neither an `--ok-match` nor an
113/// `--err-match` fired.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
115pub enum Otherwise {
116 /// Treat an inconclusive run as `SUCCESS`.
117 Success,
118 /// Treat an inconclusive run as `ERROR` (fail-closed).
119 Error,
120 /// Follow the child's exit status (`0` ⇒ `SUCCESS`).
121 Exit,
122}
123
124impl Otherwise {
125 pub fn label(self) -> &'static str {
126 match self {
127 Otherwise::Success => "success",
128 Otherwise::Error => "error",
129 Otherwise::Exit => "exit",
130 }
131 }
132}