miden_client_cli/config.rs
1use core::fmt::Debug;
2use std::fmt::Display;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5use std::time::Duration;
6
7use figment::providers::{Format, Toml};
8use figment::value::{Dict, Map};
9use figment::{Figment, Metadata, Profile, Provider};
10use miden_client::note_transport::{
11 NOTE_TRANSPORT_DEVNET_ENDPOINT,
12 NOTE_TRANSPORT_TESTNET_ENDPOINT,
13};
14use miden_client::rpc::Endpoint;
15use serde::{Deserialize, Serialize};
16
17use crate::errors::CliError;
18
19pub const MIDEN_DIR: &str = ".miden";
20pub const CLIENT_CONFIG_FILE_NAME: &str = "miden-client.toml";
21pub const TOKEN_SYMBOL_MAP_FILENAME: &str = "token_symbol_map.toml";
22pub const DEFAULT_PACKAGES_DIR: &str = "packages";
23pub const STORE_FILENAME: &str = "store.sqlite3";
24pub const KEYSTORE_DIRECTORY: &str = "keystore";
25pub const DEFAULT_REMOTE_PROVER_TIMEOUT: Duration = Duration::from_secs(20);
26
27/// Returns the global miden directory path.
28///
29/// If the `MIDEN_CLIENT_HOME` environment variable is set, returns that path directly. Otherwise,
30/// returns the `.miden` directory in the user's home directory.
31pub fn get_global_miden_dir() -> Result<PathBuf, std::io::Error> {
32 if let Ok(miden_home) = std::env::var("MIDEN_CLIENT_HOME") {
33 return Ok(PathBuf::from(miden_home));
34 }
35 dirs::home_dir()
36 .ok_or_else(|| {
37 std::io::Error::new(std::io::ErrorKind::NotFound, "Could not determine home directory")
38 })
39 .map(|home| home.join(MIDEN_DIR))
40}
41
42/// Returns the local miden directory path relative to the current working directory
43pub fn get_local_miden_dir() -> Result<PathBuf, std::io::Error> {
44 std::env::current_dir().map(|cwd| cwd.join(MIDEN_DIR))
45}
46
47// CLI CONFIG
48// ================================================================================================
49
50/// Whether the configuration was loaded from the local or global `.miden` directory.
51#[derive(Debug, Clone)]
52pub enum ConfigKind {
53 Local,
54 Global,
55}
56
57/// The `.miden` directory from which the configuration was loaded.
58#[derive(Debug, Clone)]
59pub struct ConfigDir {
60 pub path: PathBuf,
61 pub kind: ConfigKind,
62}
63
64impl std::fmt::Display for ConfigDir {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 write!(f, "{} ({:?})", self.path.display(), self.kind)
67 }
68}
69
70#[derive(Debug, Deserialize, Serialize)]
71pub struct CliConfig {
72 /// Native fee faucet for the current protocol configuration.
73 pub fee_faucet_id: Option<String>,
74 /// The directory this configuration was loaded from. Not part of the TOML file.
75 #[serde(skip)]
76 pub config_dir: Option<ConfigDir>,
77 /// Describes settings related to the RPC endpoint.
78 pub rpc: RpcConfig,
79 /// Path to the `SQLite` store file.
80 pub store_filepath: PathBuf,
81 /// Path to the directory that contains the secret key files.
82 pub secret_keys_directory: PathBuf,
83 /// Path to the file containing the token symbol map.
84 pub token_symbol_map_filepath: PathBuf,
85 /// RPC endpoint for the remote prover. If this isn't present, a local prover will be used.
86 pub remote_prover_endpoint: Option<CliEndpoint>,
87 /// Path to the directory from where packages will be loaded.
88 pub package_directory: PathBuf,
89 /// Maximum number of blocks the client can be behind the network for transactions and account
90 /// proofs to be considered valid.
91 pub max_block_number_delta: Option<u32>,
92 /// Describes settings related to the note transport endpoint.
93 pub note_transport: Option<NoteTransportConfig>,
94 /// Timeout for the remote prover requests.
95 pub remote_prover_timeout: Duration,
96}
97
98// Make `ClientConfig` a provider itself for composability.
99impl Provider for CliConfig {
100 fn metadata(&self) -> Metadata {
101 Metadata::named("CLI Config")
102 }
103
104 fn data(&self) -> Result<Map<Profile, Dict>, figment::Error> {
105 figment::providers::Serialized::defaults(CliConfig::default()).data()
106 }
107
108 fn profile(&self) -> Option<Profile> {
109 // Optionally, a profile that's selected by default.
110 None
111 }
112}
113
114/// Default implementation for `CliConfig`.
115///
116/// **Note**: This implementation is primarily used by the [`figment`] `Provider` trait (see
117/// [`CliConfig::data()`]) to provide default values during configuration merging. The paths
118/// returned are relative and intended to be resolved against a `.miden` directory.
119///
120/// For loading configuration from the filesystem, use [`CliConfig::load()`] instead.
121impl Default for CliConfig {
122 fn default() -> Self {
123 // Create paths relative to the config file location (which is in .miden directory) These
124 // will be resolved relative to the .miden directory when the config is loaded
125 Self {
126 fee_faucet_id: None,
127 config_dir: None,
128 rpc: RpcConfig::default(),
129 store_filepath: PathBuf::from(STORE_FILENAME),
130 secret_keys_directory: PathBuf::from(KEYSTORE_DIRECTORY),
131 token_symbol_map_filepath: PathBuf::from(TOKEN_SYMBOL_MAP_FILENAME),
132 remote_prover_endpoint: None,
133 package_directory: PathBuf::from(DEFAULT_PACKAGES_DIR),
134 max_block_number_delta: None,
135 note_transport: None,
136 remote_prover_timeout: DEFAULT_REMOTE_PROVER_TIMEOUT,
137 }
138 }
139}
140
141impl CliConfig {
142 /// Returns `true` when this config was loaded from the local `.miden` directory.
143 ///
144 /// This is typically set when loading via [`CliConfig::from_local_dir`] or [`CliConfig::load`]
145 /// (when local takes precedence).
146 pub fn is_local(&self) -> bool {
147 matches!(&self.config_dir, Some(ConfigDir { kind: ConfigKind::Local, .. }))
148 }
149
150 /// Returns `true` when this config was loaded from the global `.miden` directory.
151 ///
152 /// This is typically set when loading via [`CliConfig::from_global_dir`] or [`CliConfig::load`]
153 /// (when local config is not available).
154 pub fn is_global(&self) -> bool {
155 matches!(&self.config_dir, Some(ConfigDir { kind: ConfigKind::Global, .. }))
156 }
157
158 /// Loads configuration from a specific `.miden` directory.
159 ///
160 /// # ⚠️ WARNING: Advanced Use Only
161 ///
162 /// **This method bypasses the standard CLI configuration discovery logic.**
163 ///
164 /// This method loads config from an explicitly specified directory, which means:
165 /// - It does NOT check for local `.miden` directory first
166 /// - It does NOT fall back to global `~/.miden` directory
167 /// - It does NOT follow CLI priority logic
168 ///
169 /// ## Recommended Alternative
170 ///
171 /// For standard CLI-like configuration loading, use:
172 /// ```ignore
173 /// CliConfig::load() // Respects local → global priority
174 /// ```
175 ///
176 /// Or for client initialization:
177 /// ```ignore
178 /// CliClient::new().await?
179 /// ```
180 ///
181 /// ## When to use this method
182 ///
183 /// - **Testing**: When you need to test with config from a specific directory
184 /// - **Explicit Control**: When you must load from a non-standard location
185 ///
186 /// # Arguments
187 ///
188 /// * `miden_dir` - Path to the `.miden` directory containing `miden-client.toml`
189 ///
190 /// # Returns
191 ///
192 /// A configured [`CliConfig`] instance with resolved paths.
193 ///
194 /// # Errors
195 ///
196 /// Returns a [`CliError`](crate::errors::CliError):
197 /// - [`CliError::ConfigNotFound`](crate::errors::CliError::ConfigNotFound) if the config file
198 /// doesn't exist in the specified directory
199 /// - [`CliError::Config`](crate::errors::CliError::Config) if configuration file parsing fails
200 ///
201 /// # Examples
202 ///
203 /// ```no_run
204 /// use std::path::PathBuf;
205 ///
206 /// use miden_client_cli::config::CliConfig;
207 ///
208 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
209 /// // ⚠️ This bypasses standard config discovery!
210 /// let config = CliConfig::from_dir(&PathBuf::from("/path/to/.miden"))?;
211 ///
212 /// // ✅ Prefer this for CLI-like behavior:
213 /// let config = CliConfig::load()?;
214 /// # Ok(())
215 /// # }
216 /// ```
217 pub fn from_dir(miden_dir: &Path) -> Result<Self, CliError> {
218 let config_path = miden_dir.join(CLIENT_CONFIG_FILE_NAME);
219
220 if !config_path.exists() {
221 return Err(CliError::ConfigNotFound(format!(
222 "Config file does not exist at {}",
223 config_path.display()
224 )));
225 }
226
227 let mut cli_config = Self::load_from_file(&config_path)?;
228
229 // Resolve all relative paths relative to the .miden directory
230 Self::resolve_relative_path(&mut cli_config.store_filepath, miden_dir);
231 Self::resolve_relative_path(&mut cli_config.secret_keys_directory, miden_dir);
232 Self::resolve_relative_path(&mut cli_config.token_symbol_map_filepath, miden_dir);
233 Self::resolve_relative_path(&mut cli_config.package_directory, miden_dir);
234
235 Ok(cli_config)
236 }
237
238 /// Loads configuration from the local `.miden` directory (current working directory).
239 ///
240 /// # ⚠️ WARNING: Advanced Use Only
241 ///
242 /// **This method bypasses the standard CLI configuration discovery logic.**
243 ///
244 /// This method ONLY checks the local directory and does NOT fall back to the global
245 /// configuration if the local config doesn't exist. This differs from CLI behavior.
246 ///
247 /// ## Recommended Alternative
248 ///
249 /// For standard CLI-like behavior:
250 /// ```ignore
251 /// CliConfig::load() // Respects local → global fallback
252 /// CliClient::new().await?
253 /// ```
254 ///
255 /// ## When to use this method
256 ///
257 /// - **Testing**: When you need to ensure only local config is used
258 /// - **Explicit Control**: When you must avoid global config
259 ///
260 /// # Returns
261 ///
262 /// A configured [`CliConfig`] instance.
263 ///
264 /// # Errors
265 ///
266 /// Returns a [`CliError`](crate::errors::CliError) if:
267 /// - Cannot determine current working directory
268 /// - The config file doesn't exist locally
269 /// - Configuration file parsing fails
270 pub fn from_local_dir() -> Result<Self, CliError> {
271 let local_miden_dir = get_local_miden_dir()?;
272 let mut config = Self::from_dir(&local_miden_dir)?;
273 config.config_dir = Some(ConfigDir {
274 path: local_miden_dir,
275 kind: ConfigKind::Local,
276 });
277 Ok(config)
278 }
279
280 /// Loads configuration from the global `.miden` directory (user's home directory).
281 ///
282 /// # ⚠️ WARNING: Advanced Use Only
283 ///
284 /// **This method bypasses the standard CLI configuration discovery logic.**
285 ///
286 /// This method ONLY checks the global directory and does NOT check for local config first. This
287 /// differs from CLI behavior which prioritizes local config over global.
288 ///
289 /// ## Recommended Alternative
290 ///
291 /// For standard CLI-like behavior:
292 /// ```ignore
293 /// CliConfig::load() // Respects local → global priority
294 /// CliClient::new().await?
295 /// ```
296 ///
297 /// ## When to use this method
298 ///
299 /// - **Testing**: When you need to ensure only global config is used
300 /// - **Explicit Control**: When you must bypass local config
301 ///
302 /// # Returns
303 ///
304 /// A configured [`CliConfig`] instance.
305 ///
306 /// # Errors
307 ///
308 /// Returns a [`CliError`](crate::errors::CliError) if:
309 /// - Cannot determine home directory
310 /// - The config file doesn't exist globally
311 /// - Configuration file parsing fails
312 pub fn from_global_dir() -> Result<Self, CliError> {
313 let global_miden_dir = get_global_miden_dir().map_err(|e| {
314 CliError::Config(Box::new(e), "Failed to determine global config directory".to_string())
315 })?;
316 let mut config = Self::from_dir(&global_miden_dir)?;
317 config.config_dir = Some(ConfigDir {
318 path: global_miden_dir,
319 kind: ConfigKind::Global,
320 });
321 Ok(config)
322 }
323
324 /// Loads configuration from system directories with priority: local first, then global
325 /// fallback.
326 ///
327 /// # ✅ Recommended Method
328 ///
329 /// **This is the recommended method for loading CLI configuration as it follows the same
330 /// discovery logic as the CLI tool itself.**
331 ///
332 /// This method searches for configuration files in the following order:
333 /// 1. Local `.miden/miden-client.toml` in the current working directory
334 /// 2. Global `.miden/miden-client.toml` in the home directory (fallback)
335 ///
336 /// This matches the CLI's configuration priority logic. For most use cases, you should use
337 /// [`CliClient::new()`](crate::CliClient::new) instead, which uses this method internally.
338 ///
339 /// # Returns
340 ///
341 /// A configured [`CliConfig`] instance.
342 ///
343 /// # Errors
344 ///
345 /// Returns a [`CliError`](crate::errors::CliError):
346 /// - [`CliError::ConfigNotFound`](crate::errors::CliError::ConfigNotFound) if neither local nor
347 /// global config file exists
348 /// - [`CliError::Config`](crate::errors::CliError::Config) if configuration file parsing fails
349 ///
350 /// Note: If a local config file exists but has parse errors, the error is returned immediately
351 /// without falling back to global config.
352 ///
353 /// # Examples
354 ///
355 /// ```no_run
356 /// use miden_client_cli::config::CliConfig;
357 ///
358 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
359 /// // ✅ Recommended: Loads from local .miden dir if it exists, otherwise from global
360 /// let config = CliConfig::load()?;
361 ///
362 /// // Or even better, use CliClient directly:
363 /// // let client = CliClient::new().await?;
364 /// # Ok(())
365 /// # }
366 /// ```
367 pub fn load() -> Result<Self, CliError> {
368 // Try local first
369 match Self::from_local_dir() {
370 Ok(config) => Ok(config),
371 // Only fall back to global if the local config file was not found (not for parse errors
372 // or other issues)
373 Err(CliError::ConfigNotFound(_)) => {
374 // Fall back to global
375 Self::from_global_dir().map_err(|e| match e {
376 CliError::ConfigNotFound(_) => CliError::ConfigNotFound(
377 "Neither local nor global config file exists".to_string(),
378 ),
379 other => other,
380 })
381 },
382 // For other errors (like parse errors), propagate them immediately
383 Err(e) => Err(e),
384 }
385 }
386
387 /// Loads the client configuration from a TOML file.
388 fn load_from_file(config_file: &Path) -> Result<Self, CliError> {
389 Figment::from(Toml::file(config_file)).extract().map_err(|err| {
390 CliError::Config("failed to load config file".to_string().into(), err.to_string())
391 })
392 }
393
394 /// Resolves a relative path against a base directory. If the path is already absolute, it
395 /// remains unchanged.
396 fn resolve_relative_path(path: &mut PathBuf, base_dir: &Path) {
397 if path.is_relative() {
398 *path = base_dir.join(&*path);
399 }
400 }
401}
402
403// RPC CONFIG
404// ================================================================================================
405
406/// Settings for the RPC client.
407#[derive(Debug, Deserialize, Serialize)]
408pub struct RpcConfig {
409 /// Address of the Miden node to connect to.
410 pub endpoint: CliEndpoint,
411 /// Timeout for the RPC api requests, in milliseconds.
412 pub timeout_ms: u64,
413}
414
415impl Default for RpcConfig {
416 fn default() -> Self {
417 Self {
418 endpoint: Endpoint::testnet().into(),
419 timeout_ms: 10000,
420 }
421 }
422}
423
424// NOTE TRANSPORT CONFIG
425// ================================================================================================
426
427/// Settings for the note transport client.
428#[derive(Debug, Deserialize, Serialize)]
429pub struct NoteTransportConfig {
430 /// Address of the Miden Note Transport node to connect to.
431 pub endpoint: String,
432 /// Timeout for the Note Transport RPC api requests, in milliseconds.
433 pub timeout_ms: u64,
434}
435
436impl Default for NoteTransportConfig {
437 fn default() -> Self {
438 Self {
439 endpoint: NOTE_TRANSPORT_TESTNET_ENDPOINT.to_string(),
440 timeout_ms: 10000,
441 }
442 }
443}
444
445impl NoteTransportConfig {
446 /// Returns a `NoteTransportConfig` for the devnet network.
447 pub fn devnet() -> Self {
448 Self {
449 endpoint: NOTE_TRANSPORT_DEVNET_ENDPOINT.to_string(),
450 timeout_ms: 10000,
451 }
452 }
453}
454
455// CLI ENDPOINT
456// ================================================================================================
457
458#[derive(Clone, Debug)]
459pub struct CliEndpoint(pub Endpoint);
460
461impl Display for CliEndpoint {
462 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463 write!(f, "{}", self.0)
464 }
465}
466
467impl TryFrom<&str> for CliEndpoint {
468 type Error = String;
469
470 fn try_from(endpoint: &str) -> Result<Self, Self::Error> {
471 let endpoint = Endpoint::try_from(endpoint).map_err(|err| err.clone())?;
472 Ok(Self(endpoint))
473 }
474}
475
476impl From<Endpoint> for CliEndpoint {
477 fn from(endpoint: Endpoint) -> Self {
478 Self(endpoint)
479 }
480}
481
482impl TryFrom<Network> for CliEndpoint {
483 type Error = CliError;
484
485 fn try_from(value: Network) -> Result<Self, Self::Error> {
486 Ok(Self(Endpoint::try_from(value.to_rpc_endpoint().as_str()).map_err(|err| {
487 CliError::Parse(err.into(), "Failed to parse RPC endpoint".to_string())
488 })?))
489 }
490}
491
492impl From<CliEndpoint> for Endpoint {
493 fn from(endpoint: CliEndpoint) -> Self {
494 endpoint.0
495 }
496}
497
498impl From<&CliEndpoint> for Endpoint {
499 fn from(endpoint: &CliEndpoint) -> Self {
500 endpoint.0.clone()
501 }
502}
503
504impl Serialize for CliEndpoint {
505 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
506 where
507 S: serde::Serializer,
508 {
509 serializer.serialize_str(&self.to_string())
510 }
511}
512
513impl<'de> Deserialize<'de> for CliEndpoint {
514 fn deserialize<D>(deserializer: D) -> Result<CliEndpoint, D::Error>
515 where
516 D: serde::Deserializer<'de>,
517 {
518 let endpoint = String::deserialize(deserializer)?;
519 CliEndpoint::try_from(endpoint.as_str()).map_err(serde::de::Error::custom)
520 }
521}
522
523// NETWORK
524// ================================================================================================
525
526/// Represents the network to which the client connects. It is used to determine the RPC endpoint
527/// and network ID for the CLI.
528#[derive(Debug, Clone, Deserialize, Serialize)]
529pub enum Network {
530 Custom(String),
531 Devnet,
532 Localhost,
533 Testnet,
534}
535
536impl FromStr for Network {
537 type Err = String;
538
539 fn from_str(s: &str) -> Result<Self, Self::Err> {
540 match s.to_lowercase().as_str() {
541 "devnet" => Ok(Network::Devnet),
542 "localhost" => Ok(Network::Localhost),
543 "testnet" => Ok(Network::Testnet),
544 custom => Ok(Network::Custom(custom.to_string())),
545 }
546 }
547}
548
549impl Network {
550 /// Converts the Network variant to its corresponding RPC endpoint string
551 #[allow(dead_code)]
552 pub fn to_rpc_endpoint(&self) -> String {
553 match self {
554 Network::Custom(custom) => custom.clone(),
555 Network::Devnet => Endpoint::devnet().to_string(),
556 Network::Localhost => Endpoint::default().to_string(),
557 Network::Testnet => Endpoint::testnet().to_string(),
558 }
559 }
560}