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}