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. 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}