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}