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}