Skip to main content

cardanowall_cli/
cli.rs

1//! The clap command tree and the top-level dispatcher with the exit-code mapping.
2//!
3//! [`run`] is the single entry point shared by the binary and the integration
4//! tests: it parses argv, routes to a command handler, and collapses the
5//! handler's `Result<(), CliError>` into the process exit code. Clap's own parse
6//! failures (bad flags, unknown subcommands, missing required args) map to `4`;
7//! `--help` / `--version` map to `0`.
8//!
9//! ## Global flags
10//!
11//! `--color <auto|always|never>` / `--no-color` set the color policy (honoured
12//! together with `NO_COLOR` / `CLICOLOR_FORCE` / `is_terminal()`), `--quiet`
13//! suppresses non-essential stderr chatter, and a global `--json` puts the active
14//! command into machine-output mode (its own `--json` works too). When the active
15//! command is in JSON mode and fails, the dispatcher emits a structured error
16//! object on stderr instead of the plain `cardanowall: <message>` line.
17//!
18//! ## Environment
19//!
20//! Every secret / config flag has a consistent env fallback used on ALL commands:
21//!
22//! - `CARDANOWALL_BASE_URL`      ← `--base-url`
23//! - `CARDANOWALL_API_KEY`       ← `--api-key`
24//! - `CARDANOWALL_SEED`          ← `--seed`        (identity, sign, submit, inbox)
25//! - `CARDANOWALL_RECIPIENT_KEY` ← `--secret-key`  (verify, inbox)
26//! - `CARDANOWALL_CARDANO_GATEWAY` / `CARDANOWALL_ARWEAVE_GATEWAY` /
27//!   `CARDANOWALL_IPFS_GATEWAY` / `CARDANOWALL_BLOCKFROST_PROJECT_ID` /
28//!   `CARDANOWALL_CONFIRMATION_DEPTH_THRESHOLD` / `CARDANOWALL_DENY_HOST`
29//! - `CARDANOWALL_CONFIG_PATH` overrides `~/.cardanowall/config.toml`.
30
31use std::ffi::OsString;
32
33use clap::{Args, Parser, Subcommand};
34
35use crate::commands;
36use crate::util::color::ColorChoice;
37use crate::util::version::version_string;
38
39/// The `cardanowall` CLI: a standalone Label 309 Proof-of-Existence toolkit.
40#[derive(Debug, Parser)]
41#[command(
42    name = "cardanowall",
43    bin_name = "cardanowall",
44    about = "Label 309 standalone verifier and Proof-of-Existence toolkit",
45    long_about = "Label 309 standalone verifier and Proof-of-Existence toolkit.\n\n\
46        ENVIRONMENT (consistent across every command):\n  \
47        CARDANOWALL_BASE_URL       gateway base URL        (--base-url)\n  \
48        CARDANOWALL_API_KEY        opaque bearer API key   (--api-key)\n  \
49        CARDANOWALL_SEED           32-byte identity seed   (--seed)\n  \
50        CARDANOWALL_RECIPIENT_KEY  X25519 recipient key    (--secret-key)\n  \
51        CARDANOWALL_CARDANO_GATEWAY / CARDANOWALL_ARWEAVE_GATEWAY /\n  \
52        CARDANOWALL_IPFS_GATEWAY / CARDANOWALL_BLOCKFROST_PROJECT_ID /\n  \
53        CARDANOWALL_CONFIRMATION_DEPTH_THRESHOLD / CARDANOWALL_DENY_HOST\n  \
54        CARDANOWALL_CONFIG_PATH    overrides ~/.cardanowall/config.toml\n\n\
55        High-secret flags (--seed, --secret-key) also accept a *-file / *-stdin\n\
56        variant and, on a TTY, a hidden interactive prompt; the raw --seed/\n\
57        --secret-key hex flag is INSECURE (shell history / ps / CI logs).",
58    version = version_string(),
59    disable_version_flag = true
60)]
61pub struct Cli {
62    /// Print the package version, git SHA, and build date.
63    #[arg(long, action = clap::ArgAction::Version)]
64    version: Option<bool>,
65
66    /// Global color / quiet / json flags shared by every command.
67    #[command(flatten)]
68    pub global: GlobalArgs,
69
70    /// The subcommand to run.
71    #[command(subcommand)]
72    pub command: Command,
73}
74
75/// Cross-command flags marked `global = true` so they may appear before OR after
76/// the subcommand, e.g. `cardanowall --no-color verify …` or `cardanowall verify
77/// … --quiet`.
78#[derive(Debug, Clone, Default, Args)]
79pub struct GlobalArgs {
80    /// Color policy: auto (default), always, or never.
81    #[arg(long, global = true, value_enum, default_value_t = ColorMode::Auto)]
82    pub color: ColorMode,
83    /// Disable colored output (shorthand for --color never).
84    #[arg(long, global = true)]
85    pub no_color: bool,
86    /// Suppress non-essential stderr chatter.
87    #[arg(long, short = 'q', global = true)]
88    pub quiet: bool,
89    /// Machine-output mode: structured JSON on stdout, structured errors on stderr.
90    #[arg(long, global = true)]
91    pub json: bool,
92}
93
94/// The `--color` value surface (a clap value-enum mirror of [`ColorChoice`]).
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
96pub enum ColorMode {
97    /// Decide from env + TTY.
98    #[default]
99    Auto,
100    /// Always colorize.
101    Always,
102    /// Never colorize.
103    Never,
104}
105
106impl GlobalArgs {
107    /// The effective color choice: `--no-color` forces `Never`, else `--color`.
108    #[must_use]
109    pub fn color_choice(&self) -> ColorChoice {
110        if self.no_color {
111            return ColorChoice::Never;
112        }
113        match self.color {
114            ColorMode::Auto => ColorChoice::Auto,
115            ColorMode::Always => ColorChoice::Always,
116            ColorMode::Never => ColorChoice::Never,
117        }
118    }
119}
120
121/// The top-level subcommand set.
122#[derive(Debug, Subcommand)]
123pub enum Command {
124    /// Verify a Label 309 PoE record at a Cardano transaction hash.
125    Verify(commands::verify::VerifyArgs),
126    /// Anchor a Label 309 PoE on Cardano (hash / file / Merkle).
127    Submit(commands::submit::SubmitArgs),
128    /// Offline PATH-1 (identity Ed25519) record signing.
129    Sign(commands::sign::SignArgs),
130    /// Derive and print the public identity from a 32-byte master seed (offline).
131    Identity(commands::identity::IdentityArgs),
132    /// Off-chain Merkle tooling.
133    Merkle(commands::merkle::MerkleArgs),
134    /// Sealed-PoE inbox commands.
135    Inbox(commands::inbox::InboxArgs),
136    /// Manage named service-gateway profiles (endpoint + API key).
137    Gateway(commands::gateway::GatewayArgs),
138    /// Print a shell completion script.
139    Completion(commands::completion::CompletionArgs),
140}
141
142impl Command {
143    /// The command's short name, used in the structured JSON-error object.
144    fn name(&self) -> &'static str {
145        match self {
146            Command::Verify(_) => "verify",
147            Command::Submit(_) => "submit",
148            Command::Sign(_) => "sign",
149            Command::Identity(_) => "identity",
150            Command::Merkle(_) => "merkle",
151            Command::Inbox(_) => "inbox",
152            Command::Gateway(_) => "gateway",
153            Command::Completion(_) => "completion",
154        }
155    }
156
157    /// Whether this command was invoked in JSON (machine-output) mode, OR-ing the
158    /// per-command `--json` with the global one so either placement works.
159    fn json_mode(&self, global_json: bool) -> bool {
160        global_json || self.local_json()
161    }
162
163    /// The per-command `--json` flag, where the command has one.
164    fn local_json(&self) -> bool {
165        match self {
166            Command::Verify(a) => a.json,
167            Command::Submit(a) => a.json,
168            Command::Sign(a) => a.source_json(),
169            Command::Identity(a) => a.json,
170            Command::Merkle(a) => a.json_mode(),
171            Command::Inbox(a) => a.json_mode(),
172            Command::Gateway(a) => a.json_mode(),
173            Command::Completion(_) => false,
174        }
175    }
176}
177
178/// Cross-command runtime context resolved once from the global flags: the color
179/// choice and quiet mode, plus the JSON mode the dispatcher computed.
180#[derive(Debug, Clone, Copy)]
181pub struct GlobalContext {
182    /// The resolved color choice.
183    pub color: ColorChoice,
184    /// Whether non-essential stderr chatter is suppressed.
185    pub quiet: bool,
186    /// Whether the active command is in JSON (machine-output) mode.
187    pub json: bool,
188}
189
190/// Parse `args`, dispatch, and return the process exit code (`0`–`4`).
191///
192/// A clap parse error or unknown subcommand maps to `4`; `--help` / `--version`
193/// short-circuit to `0`. A handler's [`CliError`](crate::util::CliError) is
194/// printed to stderr — as `cardanowall: <message>` in human mode, or as a
195/// structured `{"error":{…}}` object in JSON mode — and its code returned.
196pub fn run<I, T>(args: I) -> i32
197where
198    I: IntoIterator<Item = T>,
199    T: Into<OsString> + Clone,
200{
201    let cli = match Cli::try_parse_from(args) {
202        Ok(cli) => cli,
203        Err(err) => {
204            // `--help` / `--version` are reported as "errors" by clap but are a
205            // success exit; everything else (bad flag, unknown subcommand,
206            // missing required arg) is a CLI input error → 4.
207            use clap::error::ErrorKind;
208            let kind = err.kind();
209            // Print to the stream clap intends (stdout for help/version).
210            let _ = err.print();
211            if matches!(
212                kind,
213                ErrorKind::DisplayHelp
214                    | ErrorKind::DisplayVersion
215                    | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
216            ) {
217                return 0;
218            }
219            return 4;
220        }
221    };
222
223    let json = cli.command.json_mode(cli.global.json);
224    let command_name = cli.command.name();
225    let ctx = GlobalContext {
226        color: cli.global.color_choice(),
227        quiet: cli.global.quiet,
228        json,
229    };
230
231    let result = match cli.command {
232        Command::Verify(args) => commands::verify::run(args),
233        Command::Submit(args) => commands::submit::run(args),
234        Command::Sign(args) => commands::sign::run(args),
235        Command::Identity(args) => commands::identity::run(args),
236        Command::Merkle(args) => commands::merkle::run(args),
237        Command::Inbox(args) => commands::inbox::run(args),
238        Command::Gateway(args) => commands::gateway::run(args),
239        Command::Completion(args) => commands::completion::run(args),
240    };
241
242    match result {
243        Ok(()) => 0,
244        Err(err) => {
245            report_error(&err, command_name, &ctx);
246            err.code
247        }
248    }
249}
250
251/// Write a command failure to stderr.
252///
253/// In human mode this is the familiar `cardanowall: <message>` line, with the
254/// prefix dyed red when color is enabled (silent when the message is empty — e.g.
255/// `verify` already printed its report). In JSON mode it is a single structured
256/// object so automation can parse the failure:
257/// `{"error":{"code":<exit_code>,"message":"<text>","command":"<name>"}}`.
258fn report_error(err: &crate::util::CliError, command: &str, ctx: &GlobalContext) {
259    if ctx.json {
260        let value = serde_json::json!({
261            "error": {
262                "code": err.code,
263                "message": err.message,
264                "command": command,
265            }
266        });
267        eprintln!("{value}");
268    } else if !err.message.is_empty() {
269        use crate::util::color::{should_color, Stream, SystemColorEnv};
270        use owo_colors::OwoColorize;
271        let colored = should_color(ctx.color, false, Stream::Stderr, &SystemColorEnv);
272        if colored {
273            eprintln!("{}: {}", "cardanowall".red(), err.message);
274        } else {
275            eprintln!("cardanowall: {}", err.message);
276        }
277    }
278}