Skip to main content

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