Skip to main content

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}