Skip to main content

decdn_common/cli/
common.rs

1//! Shared CLI types and helpers.
2
3use std::fmt;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8/// Log output format.
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum LogFormat {
12    /// Human-readable log lines.
13    #[default]
14    Pretty,
15    /// Structured JSON (one object per line).
16    Json,
17}
18
19impl fmt::Display for LogFormat {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        match self {
22            Self::Pretty => f.write_str("pretty"),
23            Self::Json => f.write_str("json"),
24        }
25    }
26}
27
28/// Blockchain coordinates + keys shared by every on-chain command, flattened
29/// into each command's args so they expose an identical flag group and can't
30/// drift. Each field is taken from a flag when present, otherwise the
31/// `[blockchain]` / `[identity]` tables of the TOML config (the same file the
32/// daemon reads). `ChainArgs` / `PublishChainArgs` layer their contract-address
33/// flags on top of this; the shared resolver (`chain_ctx::resolve_common`)
34/// reads these fields so the flag > config > default precedence lives in one
35/// place.
36#[derive(clap::Args, Debug)]
37pub struct CommonChainArgs {
38    /// Path to the TOML config file supplying `[blockchain]` / `[identity]`
39    /// fields not passed as flags. Takes precedence over the top-level
40    /// `decdn --config`; falls through to `~/.decdn/node.toml`.
41    #[arg(long, value_name = "PATH")]
42    pub config: Option<PathBuf>,
43
44    /// JSON-RPC endpoint URL. Overrides `blockchain.rpc_url`.
45    #[arg(long, value_name = "URL")]
46    pub rpc_url: Option<String>,
47
48    /// EIP-712 `chainId` for signing domains. Overrides `blockchain.chain_id`;
49    /// defaults to Arbitrum Sepolia. Must match the target contract
50    /// deployment's chain or signatures are rejected on-chain. The `publish`
51    /// and `setup` commands additionally read the RPC's own chain id and refuse
52    /// to submit on a mismatch; the other on-chain commands do not.
53    #[arg(long, value_name = "ID")]
54    pub chain_id: Option<u64>,
55
56    /// Ethereum keystore file. Overrides `blockchain.eth_keystore`; defaults
57    /// to `<data_dir>/keystore.json`.
58    #[arg(long, value_name = "PATH")]
59    pub keystore: Option<PathBuf>,
60
61    /// Data directory holding `node.secret`. Overrides `identity.data_dir`;
62    /// defaults to `~/.decdn`.
63    #[arg(long, value_name = "PATH")]
64    pub data_dir: Option<PathBuf>,
65
66    /// File whose contents are the keystore password. Consulted after the
67    /// `DECDN_KEYSTORE_PASSWORD` env var and before an interactive prompt on a
68    /// TTY. A single trailing newline is stripped; a file that is empty after
69    /// that strip is a deliberate empty password, not an absent source. A path
70    /// that does not exist falls through; one that exists but cannot be read is
71    /// an error.
72    #[arg(long, value_name = "PATH", env = "DECDN_KEYSTORE_PASSWORD_FILE")]
73    pub keystore_password_file: Option<PathBuf>,
74
75    /// Build and print what would be submitted without sending any
76    /// transaction.
77    #[arg(long)]
78    pub dry_run: bool,
79
80    /// Emit the result as JSON instead of human-readable `key=value` lines.
81    #[arg(long)]
82    pub json: bool,
83}
84
85/// Log verbosity level.
86#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum, Serialize, Deserialize)]
87#[serde(rename_all = "lowercase")]
88pub enum LogLevel {
89    /// Most verbose: all trace spans and events.
90    Trace,
91    /// Debug-level diagnostics.
92    Debug,
93    /// Standard operational information.
94    #[default]
95    Info,
96    /// Warnings only.
97    Warn,
98    /// Errors only.
99    Error,
100}
101
102impl fmt::Display for LogLevel {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        let s = match self {
105            Self::Trace => "trace",
106            Self::Debug => "debug",
107            Self::Info => "info",
108            Self::Warn => "warn",
109            Self::Error => "error",
110        };
111        f.write_str(s)
112    }
113}
114
115/// Returns the platform-appropriate default data directory (`~/.decdn`).
116///
117/// Returns `None` if the home directory cannot be determined.
118pub fn default_data_dir() -> Option<PathBuf> {
119    resolve_home().map(|h| h.join(".decdn"))
120}
121
122/// Returns the default config file path (`~/.decdn/node.toml`).
123///
124/// Returns `None` if the home directory cannot be determined.
125pub fn default_config_path() -> Option<PathBuf> {
126    default_data_dir().map(|d| d.join("node.toml"))
127}
128
129/// Returns the client-scoped default data directory (`~/.decdn/client`).
130///
131/// The `decdn` client commands (`fetch`, `bundle pull`, `pool`)
132/// keep their spending keystore and buyer-channel store here rather than in the
133/// node-shaped `~/.decdn`, so a pure client install does not masquerade as a
134/// node. An explicit `--data-dir` / `identity.data_dir` still wins.
135///
136/// Returns `None` if the home directory cannot be determined.
137pub fn default_client_data_dir() -> Option<PathBuf> {
138    default_data_dir().map(|d| d.join("client"))
139}
140
141/// Whether a TOML config path was chosen by the operator or
142/// defaulted. Drives the "missing file" policy for config-file
143/// lookups in the `decdn node *` subcommands: an explicit path that
144/// isn't there is almost certainly a typo and should error, while a
145/// default path that isn't there is normal (operator just hasn't
146/// made a config yet).
147#[derive(Debug, Clone, Copy)]
148pub enum ConfigPathSource {
149    /// Path came from `--config` (subcommand) or the global
150    /// `decdn --config` flag.
151    Explicit,
152    /// Path came from the built-in default (`~/.decdn/node.toml`).
153    Default,
154}
155
156/// Expands a leading `~/` or bare `~` in a path to the user's home directory.
157///
158/// Logs a warning and returns the path unchanged if `~` is present but
159/// the home directory cannot be determined (e.g. in minimal containers).
160/// Does not handle `~username` syntax.
161pub fn expand_tilde(path: &Path) -> PathBuf {
162    let s = path.to_string_lossy();
163    if let Some(rest) = s.strip_prefix("~/") {
164        if let Some(home) = resolve_home() {
165            return home.join(rest);
166        }
167        tracing::warn!(path = %s, "cannot expand '~': home directory not available");
168    } else if s == "~" {
169        if let Some(home) = resolve_home() {
170            return home;
171        }
172        tracing::warn!("cannot expand '~': home directory not available");
173    }
174    path.to_path_buf()
175}
176
177/// Resolve the user's home directory.
178///
179/// Wraps `dirs::home_dir()`, with a `#[cfg(test)]` thread-local seam that
180/// lets unit tests inject a deterministic home (or explicitly the
181/// "no home" branch). Non-test builds always go straight to
182/// `dirs::home_dir()`. See `test_support::with_home_override` (only
183/// compiled under `#[cfg(test)]`, so a rustdoc intra-doc link would
184/// fail to resolve in regular `cargo doc` builds).
185fn resolve_home() -> Option<PathBuf> {
186    #[cfg(test)]
187    if let Some(o) = test_support::current_home_override() {
188        return o.into_inner();
189    }
190    dirs::home_dir()
191}
192
193/// Test-only helpers for overriding the home-directory lookup.
194///
195/// These exist because both branches of `dirs::home_dir()` must be tested
196/// deterministically — and edition-2024 marks `std::env::set_var` as
197/// `unsafe`, which the workspace forbids. The override lives in a
198/// thread-local so concurrent test harness threads don't race.
199#[cfg(test)]
200pub(crate) mod test_support;
201
202#[cfg(test)]
203#[allow(
204    clippy::unwrap_used,
205    clippy::expect_used,
206    clippy::indexing_slicing,
207    clippy::panic
208)]
209mod tests;