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::{
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}