Skip to main content

enact_config/
lib.rs

1//! Enact Configuration Management
2//!
3//! Unified configuration management for Enact with:
4//! - Environment variable support for secrets (checks `ENACT_*` env vars first)
5//! - OS keychain for secrets (API keys, tokens, credentials) with fallback support
6//! - Encrypted file storage for settings (feature flags, timeouts, preferences)
7//! - Cloud sync for authenticated users (respects air-gapped mode)
8//!
9//! # Example
10//!
11//! ```rust,no_run
12//! use enact_config::{ConfigManager, RuntimeMode, default_config_path};
13//!
14//! # async fn example() -> anyhow::Result<()> {
15//! let config_path = default_config_path()?;
16//! let manager = ConfigManager::new(config_path).await?;
17//!
18//! // Load configuration
19//! let config = manager.load().await?;
20//!
21//! // Set a secret (stored in keychain)
22//! manager.set_secret("providers.azure.apiKey", "your-api-key").await?;
23//!
24//! // Set a setting (stored in encrypted file)
25//! let mut config = manager.load().await?;
26//! config.runtime.mode = RuntimeMode::Local;
27//! manager.save(&config).await?;
28//!
29//! // Save configuration
30//! manager.save(&config).await?;
31//! # Ok(())
32//! # }
33//! ```
34
35pub mod agent_def;
36pub mod config;
37pub mod doctor;
38pub mod encrypted_store;
39pub mod home;
40pub mod hook_config;
41pub mod medic;
42pub mod project_def;
43pub mod secrets;
44pub mod sync;
45
46pub use agent_def::{AgentDef, AgentRegistry, ChannelBotConfig};
47pub use config::*;
48pub use doctor::{run_checks, Check, CheckStatus, DoctorReport};
49pub use encrypted_store::{default_config_path, EncryptedStore};
50pub use home::{
51    create_config_backup, enact_home, ensure_home_dirs, load_dotenv_from_home,
52    load_enact_md_context, resolve_config_file, write_env_secret, write_yaml_at_home,
53};
54pub use hook_config::{HookConfig, HookDecision, HookEvent, HookHandler, HooksConfig};
55pub use medic::{disallowed_top_level_keys, reference_yaml, REFERENCE_FILES};
56pub use project_def::{ProjectDef, ProjectRegistry, Task, TaskBoard};
57pub use secrets::SecretManager;
58pub use sync::{SyncManager, SyncStatus};
59
60use anyhow::{Context, Result};
61use std::path::PathBuf;
62use tracing::{debug, info};
63
64/// Main configuration manager
65pub struct ConfigManager {
66    encrypted_store: EncryptedStore,
67    secrets: SecretManager,
68    sync_manager: Option<SyncManager>,
69}
70
71impl ConfigManager {
72    /// Create a new configuration manager
73    ///
74    /// # Arguments
75    /// * `config_path` - Path to the encrypted config file
76    pub async fn new(config_path: impl Into<PathBuf>) -> Result<Self> {
77        let config_path = config_path.into();
78
79        // In test mode, we might want mock store
80        if std::env::var("ENACT_USE_MOCK_SECRET_STORE").is_ok()
81            || std::env::var("CARGO_TARGET_TMPDIR").is_ok()
82        {
83            #[allow(clippy::needless_return)]
84            return Self::new_with_mock_secrets(config_path).await;
85        }
86
87        #[cfg(test)]
88        {
89            #[allow(clippy::needless_return)]
90            return Self::new_with_mock_secrets(config_path).await;
91        }
92
93        #[cfg(not(test))]
94        {
95            // Always use SecretManager which handles env vars and .env files
96            // No more OS keychain prompts!
97            let secrets = SecretManager::new();
98            // For EncryptedStore, we need to pass the secret manager if it uses it for encryption keys?
99            // EncryptedStore used to take KeychainManager to store the master key.
100            // Now that we don't have keychain, where does EncryptedStore get the master key?
101            // Usually it generates one and stores it in keychain.
102            // If keychain is gone, we must store the master key in .env? ENACT_MASTER_KEY?
103
104            // I need to check EncryptedStore implementation. It likely calls `keychain.get("enact.master.key")`.
105            // With SecretManager, it will look for ENACT_ENACT_MASTER_KEY in env.
106            // If not found, EncryptedStore usually generates it. But it can't save it back to env.
107            // So EncryptedStore setup might fail or generate a new key every time if not in .env.
108            // This effectively means configuration is ephemeral unless ENACT_MASTER_KEY is set.
109
110            // I'll proceed with updating ConfigManager, and then I MUST check EncryptedStore.
111
112            let encrypted_store =
113                EncryptedStore::new(&config_path).context("Failed to create encrypted store")?;
114
115            Ok(Self {
116                encrypted_store,
117                secrets,
118                sync_manager: None,
119            })
120        }
121    }
122
123    /// Create a new configuration manager with a mock secrets for testing
124    pub async fn new_with_mock_secrets(config_path: impl Into<PathBuf>) -> Result<Self> {
125        let config_path = config_path.into();
126        let mock_secrets = SecretManager::new_mock();
127        // EncryptedStore needs to be updated to take SecretManager
128        // For now, assume EncryptedStore still expects KeychainManager?
129        // I need to update EncryptedStore too!
130
131        // This tool call only updates lib.rs. I'll need to update encrypted_store.rs next.
132        // I will temporarily comment out EncryptedStore usage here or assume it's updated.
133        // But `EncryptedStore::with_keychain` signature will change.
134
135        // Let's assume I will rename `with_keychain` to `with_secrets`.
136        let encrypted_store = EncryptedStore::with_secrets(&config_path, mock_secrets.clone())
137            .context("Failed to create encrypted store")?;
138
139        Ok(Self {
140            encrypted_store,
141            secrets: mock_secrets,
142            sync_manager: None,
143        })
144    }
145
146    /// Create a configuration manager with cloud sync enabled
147    pub async fn with_sync(
148        config_path: impl Into<PathBuf>,
149        api_url: Option<String>,
150        tenant_id: Option<String>,
151        auto_sync: bool,
152        runtime_mode: RuntimeMode,
153    ) -> Result<Self> {
154        let mut manager = Self::new(config_path).await?;
155        manager.sync_manager = Some(SyncManager::new(
156            api_url,
157            tenant_id,
158            auto_sync,
159            runtime_mode,
160        ));
161
162        let mut config = manager.load().await?;
163        let mut changed = false;
164        if config.runtime.mode != runtime_mode {
165            config.runtime.mode = runtime_mode;
166            changed = true;
167        }
168
169        if matches!(runtime_mode, RuntimeMode::AirGapped) && config.runtime.allow_network {
170            config.runtime.allow_network = false;
171            changed = true;
172        }
173
174        if changed {
175            manager.save(&config).await?;
176        }
177
178        Ok(manager)
179    }
180
181    /// Load configuration from encrypted file
182    pub async fn load(&self) -> Result<Config> {
183        match self.encrypted_store.load()? {
184            Some(json) => {
185                let config: Config =
186                    serde_json::from_str(&json).context("Failed to parse configuration")?;
187                debug!("Loaded configuration from encrypted store");
188                Ok(config)
189            }
190            None => {
191                debug!("No configuration found, using defaults");
192                Ok(Config::default())
193            }
194        }
195    }
196
197    /// Save configuration to encrypted file
198    pub async fn save(&self, config: &Config) -> Result<()> {
199        let json =
200            serde_json::to_string_pretty(config).context("Failed to serialize configuration")?;
201
202        self.encrypted_store.save(&json)?;
203
204        // Auto-sync to cloud if enabled
205        if let Some(ref sync_manager) = self.sync_manager {
206            if sync_manager.is_enabled() {
207                info!("Auto-syncing configuration to cloud");
208                if let Err(e) = sync_manager.sync_to_cloud(config).await {
209                    tracing::warn!("Failed to sync configuration to cloud: {}", e);
210                }
211            }
212        }
213
214        Ok(())
215    }
216
217    /// Set a secret value
218    /// Note: With SecretManager, this might fail if not using mock store.
219    /// Users should set secrets in .env.
220    pub async fn set_secret(&self, key: &str, value: &str) -> Result<()> {
221        self.secrets.set(key, value)?;
222        debug!("Set secret: {}", key);
223        Ok(())
224    }
225
226    /// Get a secret value
227    pub async fn get_secret(&self, key: &str) -> Result<Option<String>> {
228        self.secrets.get(key)
229    }
230
231    /// Delete a secret
232    pub async fn delete_secret(&self, key: &str) -> Result<()> {
233        self.secrets.delete(key)?;
234        debug!("Deleted secret: {}", key);
235        Ok(())
236    }
237
238    /// Sync configuration from cloud
239    pub async fn sync_from_cloud(&self) -> Result<Option<Config>> {
240        if let Some(ref sync_manager) = self.sync_manager {
241            sync_manager.sync_from_cloud().await
242        } else {
243            Ok(None)
244        }
245    }
246
247    /// Sync configuration to cloud
248    pub async fn sync_to_cloud(&self, config: &Config) -> Result<Option<sync::SyncResponse>> {
249        if let Some(ref sync_manager) = self.sync_manager {
250            sync_manager.sync_to_cloud(config).await
251        } else {
252            Ok(None)
253        }
254    }
255
256    /// Get sync status
257    pub fn sync_status(&self) -> SyncStatus {
258        self.sync_manager
259            .as_ref()
260            .map(|m| m.status())
261            .unwrap_or(SyncStatus::Disabled)
262    }
263
264    /// Get the config file path
265    pub fn config_path(&self) -> &std::path::Path {
266        self.encrypted_store.config_path()
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use tempfile::TempDir;
274
275    #[tokio::test]
276    async fn test_config_manager() {
277        let temp_dir = TempDir::new().unwrap();
278        let config_path = temp_dir.path().join("test_config.encrypted");
279        let manager = ConfigManager::new_with_mock_secrets(&config_path)
280            .await
281            .unwrap();
282
283        // Test default config
284        let config = manager.load().await.unwrap();
285        assert_eq!(config.runtime.mode, RuntimeMode::Local);
286
287        // Test save and load
288        let mut config = Config::default();
289        config.runtime.mode = RuntimeMode::AirGapped;
290        manager.save(&config).await.unwrap();
291
292        let loaded = manager.load().await.unwrap();
293        assert_eq!(loaded.runtime.mode, RuntimeMode::AirGapped);
294    }
295
296    #[tokio::test]
297    async fn test_secret_management() {
298        let temp_dir = TempDir::new().unwrap();
299        let config_path = temp_dir.path().join("test_config.encrypted");
300        let manager = ConfigManager::new_with_mock_secrets(&config_path)
301            .await
302            .unwrap();
303
304        // Test set and get secret
305        manager.set_secret("test.key", "test_value").await.unwrap();
306        let value = manager.get_secret("test.key").await.unwrap();
307        assert_eq!(value, Some("test_value".to_string()));
308
309        // Test delete
310        manager.delete_secret("test.key").await.unwrap();
311        let value = manager.get_secret("test.key").await.unwrap();
312        assert_eq!(value, None);
313    }
314}