Skip to main content

foundry_cli/
machine.rs

1//! Machine mode (`--machine`) — agent-contract output selector.
2//!
3//! See [`docs/agents/spec.md`](../../../docs/agents/spec.md) §10. This
4//! module ships the **runtime layer** of `--machine`: pre-parse detection,
5//! clap-error interception, and the canonical [`ExitCode`] mapping for
6//! pre-command exits.
7//!
8//! Runtime guarantees, regardless of which command is invoked:
9//!
10//! - color is disabled (wired through [`crate::opts::GlobalArgs::shell`]),
11//! - parse / usage failures are wrapped in an error envelope (`cli.usage.invalid`, exit `2`),
12//! - `--help` / `--version` are wrapped in a success envelope (exit `0`).
13//!
14//! Per-command behavior — emitting only the declared
15//! [`output_mode`](crate::introspect::OutputMode), suppressing progress
16//! bars and interactive prompts, returning the canonical [`ExitCode`] for
17//! the failure category — is opt-in and adopted incrementally.
18//!
19//! The flag is detected before clap parsing — see [`check_machine`] — so
20//! the mode is known by the time clap errors need to be intercepted.
21
22use crate::{
23    diagnostic,
24    exit_code::ExitCode,
25    json::{JsonEnvelope, JsonMessage, print_json},
26};
27use clap::{CommandFactory, Parser, error::ErrorKind};
28use serde_json::json;
29use std::sync::atomic::{AtomicBool, Ordering};
30
31static MACHINE_MODE: AtomicBool = AtomicBool::new(false);
32
33/// Returns whether `--machine` was set on the current invocation.
34///
35/// Only meaningful after [`check_machine`] has run.
36pub fn is_machine() -> bool {
37    MACHINE_MODE.load(Ordering::Relaxed)
38}
39
40/// Force machine mode on or off. Intentionally crate-private: production
41/// activation goes through [`check_machine`] (pre-parse) or
42/// [`crate::opts::GlobalArgs::init`] (post-parse re-sync).
43pub(crate) fn set_machine(on: bool) {
44    MACHINE_MODE.store(on, Ordering::Relaxed);
45}
46
47/// Pre-parse scan for `--machine`.
48///
49/// Runs before clap parsing so the flag is visible while intercepting parse
50/// errors. Honors `--machine`'s clap-global declaration, so `cast call
51/// --machine --help` also flips the mode.
52pub fn check_machine() {
53    if crate::opts::pre_parse_global_flag_present("--machine") {
54        set_machine(true);
55    }
56}
57
58/// Parse arguments, intercepting clap errors when machine mode is on.
59///
60/// Replaces `T::parse()` at binary entry points. Under `--machine`, parse
61/// errors and `--help` / `--version` are converted into a structured
62/// [`JsonEnvelope`] on stdout and the process exits with the appropriate
63/// [`ExitCode`]. Without `--machine`, behaves exactly like
64/// [`Parser::parse`].
65pub fn parse_or_exit<T: Parser + CommandFactory>() -> T {
66    if is_machine() {
67        // `GlobalArgs::init()` (which calls `yansi::disable()`) hasn't run
68        // yet; force `ColorChoice::Never` on the command so clap's rendered
69        // help / error text never embeds ANSI escapes in the envelope.
70        let mut cmd = T::command().color(clap::ColorChoice::Never);
71        let mut matches = match cmd.try_get_matches_from_mut(std::env::args_os()) {
72            Ok(m) => m,
73            Err(err) => handle_machine_clap_error(err),
74        };
75        match T::from_arg_matches_mut(&mut matches) {
76            Ok(t) => t,
77            Err(err) => handle_machine_clap_error(err),
78        }
79    } else {
80        match T::try_parse() {
81            Ok(t) => t,
82            Err(err) => err.exit(),
83        }
84    }
85}
86
87/// Convert a clap error into a structured machine-mode envelope and exit.
88///
89/// - `DisplayHelp` / `DisplayVersion` (explicit `--help` / `--version`) → success envelope wrapping
90///   clap's already-rendered, context-aware text (so e.g. `cast call --help` yields `cast call`
91///   help, not root help), exit `0`.
92/// - Everything else (parse errors, missing subcommand, missing required arg, conflict, including
93///   `DisplayHelpOnMissingArgumentOrSubcommand`, which is clap's "render help because args were
94///   missing" — i.e. a usage failure, not a help request) → error envelope with
95///   `cli.usage.invalid`, exit `2`.
96fn handle_machine_clap_error(err: clap::Error) -> ! {
97    let exit = exit_code_for_clap_error(&err);
98    match err.kind() {
99        ErrorKind::DisplayHelp => {
100            let rendered = err.render().to_string();
101            let envelope = JsonEnvelope::success(json!({ "help": rendered }));
102            let _ = print_json(&envelope);
103        }
104        ErrorKind::DisplayVersion => {
105            let rendered = err.render().to_string();
106            let envelope = JsonEnvelope::success(json!({ "version": rendered }));
107            let _ = print_json(&envelope);
108        }
109        _ => {
110            // Includes `DisplayHelpOnMissingArgumentOrSubcommand`: clap
111            // rendered help text, but the underlying cause is a missing
112            // required arg or subcommand — a usage failure by contract.
113            let message = err.to_string();
114            let envelope =
115                JsonEnvelope::error(JsonMessage::error(diagnostic::cli::USAGE_INVALID, message));
116            let _ = print_json(&envelope);
117        }
118    }
119    std::process::exit(exit.to_i32());
120}
121
122/// Maps a clap error kind to the canonical [`ExitCode`].
123///
124/// Only `DisplayHelp` and `DisplayVersion` (explicit `--help` / `--version`)
125/// are successes; everything else (parse errors, missing subcommand,
126/// missing required arg, conflict, `DisplayHelpOnMissingArgumentOrSubcommand`)
127/// is `Usage`.
128fn exit_code_for_clap_error(err: &clap::Error) -> ExitCode {
129    match err.kind() {
130        ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => ExitCode::Success,
131        _ => ExitCode::Usage,
132    }
133}
134
135/// Emit a `cli.usage.invalid` envelope on stdout and exit with
136/// [`ExitCode::Usage`] (`2`). Use at call sites that intentionally reject
137/// a flag combination under `--machine`.
138pub fn bail_machine_usage(message: impl Into<String>) -> ! {
139    let envelope = JsonEnvelope::error(JsonMessage::error(diagnostic::cli::USAGE_INVALID, message));
140    let _ = print_json(&envelope);
141    std::process::exit(ExitCode::Usage.to_i32());
142}
143
144/// Like [`bail_machine_usage`] but attaches structured `details` so agents
145/// can react without parsing the prose `message`.
146pub fn bail_machine_usage_with_details(
147    message: impl Into<String>,
148    details: serde_json::Value,
149) -> ! {
150    let envelope = JsonEnvelope::error(
151        JsonMessage::error(diagnostic::cli::USAGE_INVALID, message).with_details(details),
152    );
153    let _ = print_json(&envelope);
154    std::process::exit(ExitCode::Usage.to_i32());
155}
156
157/// Fallback envelope emitter for an untyped `eyre::Report`. Always tags
158/// `cli.unknown` and preserves the eyre cause chain in `details.cause_chain`.
159/// The process exit code is the caller's responsibility.
160pub fn report_machine_error(report: &eyre::Report) {
161    let cause_chain: Vec<String> = report.chain().map(ToString::to_string).collect();
162    let message = cause_chain.first().cloned().unwrap_or_else(|| report.to_string());
163    let envelope = JsonEnvelope::error(
164        JsonMessage::error(diagnostic::cli::UNKNOWN, message)
165            .with_details(json!({ "cause_chain": cause_chain })),
166    );
167    let _ = print_json(&envelope);
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn machine_flag_default_off() {
176        set_machine(false);
177        assert!(!is_machine());
178    }
179
180    #[test]
181    fn machine_flag_can_be_toggled() {
182        set_machine(true);
183        assert!(is_machine());
184        set_machine(false);
185        assert!(!is_machine());
186    }
187
188    #[derive(Debug, Parser)]
189    #[command(name = "demo", version = "0.1.0")]
190    struct Demo {
191        #[arg(long)]
192        name: Option<String>,
193        #[command(subcommand)]
194        cmd: Option<DemoSub>,
195    }
196
197    #[derive(Debug, clap::Subcommand)]
198    enum DemoSub {
199        /// Build the project.
200        Build {
201            #[arg(long)]
202            path: Option<String>,
203        },
204    }
205
206    #[derive(Debug, Parser)]
207    #[command(
208        name = "strict",
209        version = "0.1.0",
210        subcommand_required = true,
211        arg_required_else_help = true
212    )]
213    struct Strict {
214        #[command(subcommand)]
215        cmd: StrictSub,
216    }
217
218    #[derive(Debug, clap::Subcommand)]
219    enum StrictSub {
220        Run,
221    }
222
223    /// `report_machine_error` always tags `cli.unknown` and preserves the
224    /// full eyre cause chain in `errors[0].details.cause_chain`.
225    #[test]
226    fn report_machine_error_uses_cli_unknown_and_preserves_cause_chain() {
227        use eyre::WrapErr as _;
228        let leaf: eyre::Report = eyre::eyre!("solc: missing semicolon");
229        let report: eyre::Report = Result::<(), _>::Err(leaf)
230            .wrap_err("compile failed")
231            .wrap_err("build failed")
232            .unwrap_err();
233
234        let cause_chain: Vec<String> = report.chain().map(ToString::to_string).collect();
235        let message = cause_chain.first().cloned().unwrap();
236        let envelope = JsonEnvelope::error(
237            JsonMessage::error(diagnostic::cli::UNKNOWN, message)
238                .with_details(json!({ "cause_chain": cause_chain })),
239        );
240
241        assert!(!envelope.success);
242        assert_eq!(envelope.errors.len(), 1);
243        assert_eq!(envelope.errors[0].code, diagnostic::cli::UNKNOWN);
244        assert_eq!(envelope.errors[0].message, "build failed");
245        let details = envelope.errors[0].details.as_ref().expect("details");
246        let chain = details.get("cause_chain").and_then(|v| v.as_array()).expect("chain");
247        assert_eq!(chain.len(), 3);
248        assert_eq!(chain[0], "build failed");
249        assert_eq!(chain[2], "solc: missing semicolon");
250    }
251
252    #[test]
253    fn clap_error_kinds_map_to_exit_codes() {
254        let bad = Demo::try_parse_from(["demo", "--unknown"]).unwrap_err();
255        assert_eq!(exit_code_for_clap_error(&bad), ExitCode::Usage);
256
257        let help = Demo::try_parse_from(["demo", "--help"]).unwrap_err();
258        assert_eq!(exit_code_for_clap_error(&help), ExitCode::Success);
259
260        let version = Demo::try_parse_from(["demo", "--version"]).unwrap_err();
261        assert_eq!(exit_code_for_clap_error(&version), ExitCode::Success);
262    }
263
264    /// `DisplayHelpOnMissingArgumentOrSubcommand` is clap's "render help
265    /// because args were missing" — a usage failure, not a help request.
266    /// The agent contract maps it to [`ExitCode::Usage`], not `Success`.
267    #[test]
268    fn missing_required_subcommand_classifies_as_usage() {
269        let err = Strict::try_parse_from(["strict"]).unwrap_err();
270        assert_eq!(err.kind(), ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand);
271        assert_eq!(exit_code_for_clap_error(&err), ExitCode::Usage);
272    }
273
274    /// Subcommand `--help` must surface the **subcommand's** help, not the
275    /// root help. This is the contract test for the I3 fix: rendering
276    /// flows through `err.render()` instead of `T::command().render_help()`.
277    #[test]
278    fn subcommand_help_preserves_command_context() {
279        let err = Demo::try_parse_from(["demo", "build", "--help"]).unwrap_err();
280        assert_eq!(err.kind(), ErrorKind::DisplayHelp);
281        let rendered = err.render().to_string();
282        assert!(
283            rendered.contains("Build the project"),
284            "subcommand help should mention the subcommand description, got: {rendered}"
285        );
286        // Root-only subcommand list MUST NOT appear in subcommand help.
287        assert!(
288            !rendered.contains("Usage: demo [OPTIONS]"),
289            "subcommand help leaked root usage: {rendered}"
290        );
291    }
292}