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