Skip to main content

atupa_core/
config.rs

1//! [`AtupaConfig`] — runtime configuration with multi-source merging and validation.
2
3use figment::{
4    providers::{Env, Format, Serialized, Toml},
5    Figment,
6};
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9
10/// Runtime configuration for the Atupa profiling engine.
11///
12/// Configuration is loaded by merging multiple sources in the following priority
13/// order (highest to lowest):
14///
15/// 1. **CLI flags** — applied by the caller *after* [`AtupaConfig::load`] returns.
16/// 2. **`ATUPA_*` environment variables** — e.g. `ATUPA_RPC_URL`, `ATUPA_ETHERSCAN_KEY`.
17/// 3. **`atupa.toml`** — local project config in the current working directory.
18/// 4. **`~/.atupa/config.toml`** — global user config.
19/// 5. **Built-in defaults** — see [`AtupaConfig::default`].
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct AtupaConfig {
22    /// JSON-RPC endpoint URL for the target chain.
23    pub rpc_url: String,
24    /// Optional Etherscan API key for contract name resolution.
25    pub etherscan_key: Option<String>,
26    /// Directory where profiling artifacts (SVGs, JSON reports) are written.
27    pub output_dir: String,
28    /// Path to the Atupa Studio directory (overrides auto-detection when set).
29    pub studio_dir: Option<PathBuf>,
30    /// TCP port Atupa Studio's embedded server will bind to.
31    pub studio_port: u16,
32}
33
34impl Default for AtupaConfig {
35    fn default() -> Self {
36        Self {
37            rpc_url: "http://localhost:8547".to_string(),
38            etherscan_key: None,
39            output_dir: ".".to_string(),
40            studio_dir: None,
41            studio_port: 5173,
42        }
43    }
44}
45
46impl AtupaConfig {
47    /// Load configuration by merging all available sources.
48    ///
49    /// Config parse errors are logged as warnings and fall back to defaults
50    /// rather than panicking, ensuring the CLI remains usable even with a
51    /// malformed config file.
52    pub fn load() -> Self {
53        match Self::build_figment().extract::<Self>() {
54            Ok(config) => config,
55            Err(e) => {
56                log::warn!(
57                    "Failed to parse Atupa configuration — falling back to defaults. \
58                     Check your atupa.toml or ~/.atupa/config.toml. Error: {e}"
59                );
60                Self::default()
61            }
62        }
63    }
64
65    /// Validate that this configuration is internally coherent.
66    ///
67    /// Returns an error describing the problem if any required field is invalid.
68    ///
69    /// # Errors
70    ///
71    /// - [`rpc_url`](AtupaConfig::rpc_url) is empty or whitespace-only.
72    pub fn validate(&self) -> anyhow::Result<()> {
73        if self.rpc_url.trim().is_empty() {
74            anyhow::bail!(
75                "rpc_url must not be empty. \
76                 Set it via the ATUPA_RPC_URL environment variable, atupa.toml, or the --rpc flag."
77            );
78        }
79        Ok(())
80    }
81
82    // ── Private helpers ───────────────────────────────────────────────────────
83
84    fn build_figment() -> Figment {
85        let mut figment = Figment::from(Serialized::defaults(Self::default()));
86
87        // 1. Global user config: ~/.atupa/config.toml
88        if let Some(home) = dirs::home_dir() {
89            figment = figment.merge(Toml::file(home.join(".atupa").join("config.toml")));
90        }
91
92        // 2. Local project config: ./atupa.toml
93        figment = figment.merge(Toml::file("atupa.toml"));
94
95        // 3. Environment variable overrides
96        figment = figment.merge(Env::prefixed("ATUPA_"));
97
98        figment
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use std::sync::Mutex;
106
107    /// Global mutex to serialise tests that mutate process environment variables.
108    ///
109    /// `std::env::set_var` / `remove_var` are inherently unsound in a
110    /// multi-threaded process (they race with reads from other threads).
111    /// Holding this lock ensures our env-mutating tests never overlap.
112    static ENV_LOCK: Mutex<()> = Mutex::new(());
113
114    #[test]
115    fn default_values_are_sane() {
116        let cfg = AtupaConfig::default();
117        assert_eq!(cfg.rpc_url, "http://localhost:8547");
118        assert_eq!(cfg.studio_port, 5173);
119        assert_eq!(cfg.output_dir, ".");
120        assert!(cfg.etherscan_key.is_none());
121        assert!(cfg.studio_dir.is_none());
122    }
123
124    #[test]
125    fn validate_rejects_empty_rpc_url() {
126        let cfg = AtupaConfig { rpc_url: String::new(), ..Default::default() };
127        assert!(cfg.validate().is_err(), "empty rpc_url should fail validation");
128    }
129
130    #[test]
131    fn validate_rejects_whitespace_only_rpc_url() {
132        let cfg = AtupaConfig { rpc_url: "   ".to_string(), ..Default::default() };
133        assert!(cfg.validate().is_err(), "whitespace-only rpc_url should fail validation");
134    }
135
136    #[test]
137    fn validate_accepts_default_config() {
138        assert!(
139            AtupaConfig::default().validate().is_ok(),
140            "default config should pass validation"
141        );
142    }
143
144    #[test]
145    fn env_vars_override_rpc_url_and_key() {
146        // Safety: ENV_LOCK ensures no other test mutates the environment concurrently.
147        let _guard = ENV_LOCK.lock().unwrap();
148
149        unsafe {
150            std::env::set_var("ATUPA_RPC_URL", "http://test-rpc.local");
151            std::env::set_var("ATUPA_ETHERSCAN_KEY", "test-key-123");
152        }
153
154        let cfg = AtupaConfig::load();
155
156        // Restore env state before any assertion can panic.
157        unsafe {
158            std::env::remove_var("ATUPA_RPC_URL");
159            std::env::remove_var("ATUPA_ETHERSCAN_KEY");
160        }
161
162        assert_eq!(cfg.rpc_url, "http://test-rpc.local");
163        assert_eq!(cfg.etherscan_key, Some("test-key-123".to_string()));
164    }
165}