enact-config 0.0.2

Unified configuration management for Enact - secure storage with keychain and encrypted files
Documentation
//! Enact Configuration Management
//!
//! Unified configuration management for Enact with:
//! - Environment variable support for secrets (checks `ENACT_*` env vars first)
//! - OS keychain for secrets (API keys, tokens, credentials) with fallback support
//! - Encrypted file storage for settings (feature flags, timeouts, preferences)
//! - Cloud sync for authenticated users (respects air-gapped mode)
//!
//! # Example
//!
//! ```rust,no_run
//! use enact_config::{ConfigManager, RuntimeMode, default_config_path};
//!
//! # async fn example() -> anyhow::Result<()> {
//! let config_path = default_config_path()?;
//! let manager = ConfigManager::new(config_path).await?;
//!
//! // Load configuration
//! let config = manager.load().await?;
//!
//! // Set a secret (stored in keychain)
//! manager.set_secret("providers.azure.apiKey", "your-api-key").await?;
//!
//! // Set a setting (stored in encrypted file)
//! let mut config = manager.load().await?;
//! config.runtime.mode = RuntimeMode::Local;
//! manager.save(&config).await?;
//!
//! // Save configuration
//! manager.save(&config).await?;
//! # Ok(())
//! # }
//! ```

pub mod agent_def;
pub mod config;
pub mod doctor;
pub mod encrypted_store;
pub mod home;
pub mod hook_config;
pub mod medic;
pub mod project_def;
pub mod secrets;
pub mod sync;

pub use agent_def::{AgentDef, AgentRegistry, ChannelBotConfig};
pub use config::*;
pub use doctor::{run_checks, Check, CheckStatus, DoctorReport};
pub use encrypted_store::{default_config_path, EncryptedStore};
pub use home::{
    create_config_backup, enact_home, ensure_home_dirs, load_dotenv_from_home,
    load_enact_md_context, resolve_config_file, write_env_secret, write_yaml_at_home,
};
pub use hook_config::{HookConfig, HookDecision, HookEvent, HookHandler, HooksConfig};
pub use medic::{disallowed_top_level_keys, reference_yaml, REFERENCE_FILES};
pub use project_def::{ProjectDef, ProjectRegistry, Task, TaskBoard};
pub use secrets::SecretManager;
pub use sync::{SyncManager, SyncStatus};

use anyhow::{Context, Result};
use std::path::PathBuf;
use tracing::{debug, info};

/// Main configuration manager
pub struct ConfigManager {
    encrypted_store: EncryptedStore,
    secrets: SecretManager,
    sync_manager: Option<SyncManager>,
}

impl ConfigManager {
    /// Create a new configuration manager
    ///
    /// # Arguments
    /// * `config_path` - Path to the encrypted config file
    pub async fn new(config_path: impl Into<PathBuf>) -> Result<Self> {
        let config_path = config_path.into();

        // In test mode, we might want mock store
        if std::env::var("ENACT_USE_MOCK_SECRET_STORE").is_ok()
            || std::env::var("CARGO_TARGET_TMPDIR").is_ok()
        {
            #[allow(clippy::needless_return)]
            return Self::new_with_mock_secrets(config_path).await;
        }

        #[cfg(test)]
        {
            #[allow(clippy::needless_return)]
            return Self::new_with_mock_secrets(config_path).await;
        }

        #[cfg(not(test))]
        {
            // Always use SecretManager which handles env vars and .env files
            // No more OS keychain prompts!
            let secrets = SecretManager::new();
            // For EncryptedStore, we need to pass the secret manager if it uses it for encryption keys?
            // EncryptedStore used to take KeychainManager to store the master key.
            // Now that we don't have keychain, where does EncryptedStore get the master key?
            // Usually it generates one and stores it in keychain.
            // If keychain is gone, we must store the master key in .env? ENACT_MASTER_KEY?

            // I need to check EncryptedStore implementation. It likely calls `keychain.get("enact.master.key")`.
            // With SecretManager, it will look for ENACT_ENACT_MASTER_KEY in env.
            // If not found, EncryptedStore usually generates it. But it can't save it back to env.
            // So EncryptedStore setup might fail or generate a new key every time if not in .env.
            // This effectively means configuration is ephemeral unless ENACT_MASTER_KEY is set.

            // I'll proceed with updating ConfigManager, and then I MUST check EncryptedStore.

            let encrypted_store =
                EncryptedStore::new(&config_path).context("Failed to create encrypted store")?;

            Ok(Self {
                encrypted_store,
                secrets,
                sync_manager: None,
            })
        }
    }

    /// Create a new configuration manager with a mock secrets for testing
    pub async fn new_with_mock_secrets(config_path: impl Into<PathBuf>) -> Result<Self> {
        let config_path = config_path.into();
        let mock_secrets = SecretManager::new_mock();
        // EncryptedStore needs to be updated to take SecretManager
        // For now, assume EncryptedStore still expects KeychainManager?
        // I need to update EncryptedStore too!

        // This tool call only updates lib.rs. I'll need to update encrypted_store.rs next.
        // I will temporarily comment out EncryptedStore usage here or assume it's updated.
        // But `EncryptedStore::with_keychain` signature will change.

        // Let's assume I will rename `with_keychain` to `with_secrets`.
        let encrypted_store = EncryptedStore::with_secrets(&config_path, mock_secrets.clone())
            .context("Failed to create encrypted store")?;

        Ok(Self {
            encrypted_store,
            secrets: mock_secrets,
            sync_manager: None,
        })
    }

    /// Create a configuration manager with cloud sync enabled
    pub async fn with_sync(
        config_path: impl Into<PathBuf>,
        api_url: Option<String>,
        tenant_id: Option<String>,
        auto_sync: bool,
        runtime_mode: RuntimeMode,
    ) -> Result<Self> {
        let mut manager = Self::new(config_path).await?;
        manager.sync_manager = Some(SyncManager::new(
            api_url,
            tenant_id,
            auto_sync,
            runtime_mode,
        ));

        let mut config = manager.load().await?;
        let mut changed = false;
        if config.runtime.mode != runtime_mode {
            config.runtime.mode = runtime_mode;
            changed = true;
        }

        if matches!(runtime_mode, RuntimeMode::AirGapped) && config.runtime.allow_network {
            config.runtime.allow_network = false;
            changed = true;
        }

        if changed {
            manager.save(&config).await?;
        }

        Ok(manager)
    }

    /// Load configuration from encrypted file
    pub async fn load(&self) -> Result<Config> {
        match self.encrypted_store.load()? {
            Some(json) => {
                let config: Config =
                    serde_json::from_str(&json).context("Failed to parse configuration")?;
                debug!("Loaded configuration from encrypted store");
                Ok(config)
            }
            None => {
                debug!("No configuration found, using defaults");
                Ok(Config::default())
            }
        }
    }

    /// Save configuration to encrypted file
    pub async fn save(&self, config: &Config) -> Result<()> {
        let json =
            serde_json::to_string_pretty(config).context("Failed to serialize configuration")?;

        self.encrypted_store.save(&json)?;

        // Auto-sync to cloud if enabled
        if let Some(ref sync_manager) = self.sync_manager {
            if sync_manager.is_enabled() {
                info!("Auto-syncing configuration to cloud");
                if let Err(e) = sync_manager.sync_to_cloud(config).await {
                    tracing::warn!("Failed to sync configuration to cloud: {}", e);
                }
            }
        }

        Ok(())
    }

    /// Set a secret value
    /// Note: With SecretManager, this might fail if not using mock store.
    /// Users should set secrets in .env.
    pub async fn set_secret(&self, key: &str, value: &str) -> Result<()> {
        self.secrets.set(key, value)?;
        debug!("Set secret: {}", key);
        Ok(())
    }

    /// Get a secret value
    pub async fn get_secret(&self, key: &str) -> Result<Option<String>> {
        self.secrets.get(key)
    }

    /// Delete a secret
    pub async fn delete_secret(&self, key: &str) -> Result<()> {
        self.secrets.delete(key)?;
        debug!("Deleted secret: {}", key);
        Ok(())
    }

    /// Sync configuration from cloud
    pub async fn sync_from_cloud(&self) -> Result<Option<Config>> {
        if let Some(ref sync_manager) = self.sync_manager {
            sync_manager.sync_from_cloud().await
        } else {
            Ok(None)
        }
    }

    /// Sync configuration to cloud
    pub async fn sync_to_cloud(&self, config: &Config) -> Result<Option<sync::SyncResponse>> {
        if let Some(ref sync_manager) = self.sync_manager {
            sync_manager.sync_to_cloud(config).await
        } else {
            Ok(None)
        }
    }

    /// Get sync status
    pub fn sync_status(&self) -> SyncStatus {
        self.sync_manager
            .as_ref()
            .map(|m| m.status())
            .unwrap_or(SyncStatus::Disabled)
    }

    /// Get the config file path
    pub fn config_path(&self) -> &std::path::Path {
        self.encrypted_store.config_path()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[tokio::test]
    async fn test_config_manager() {
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("test_config.encrypted");
        let manager = ConfigManager::new_with_mock_secrets(&config_path)
            .await
            .unwrap();

        // Test default config
        let config = manager.load().await.unwrap();
        assert_eq!(config.runtime.mode, RuntimeMode::Local);

        // Test save and load
        let mut config = Config::default();
        config.runtime.mode = RuntimeMode::AirGapped;
        manager.save(&config).await.unwrap();

        let loaded = manager.load().await.unwrap();
        assert_eq!(loaded.runtime.mode, RuntimeMode::AirGapped);
    }

    #[tokio::test]
    async fn test_secret_management() {
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("test_config.encrypted");
        let manager = ConfigManager::new_with_mock_secrets(&config_path)
            .await
            .unwrap();

        // Test set and get secret
        manager.set_secret("test.key", "test_value").await.unwrap();
        let value = manager.get_secret("test.key").await.unwrap();
        assert_eq!(value, Some("test_value".to_string()));

        // Test delete
        manager.delete_secret("test.key").await.unwrap();
        let value = manager.get_secret("test.key").await.unwrap();
        assert_eq!(value, None);
    }
}