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