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};
pub struct ConfigManager {
encrypted_store: EncryptedStore,
secrets: SecretManager,
sync_manager: Option<SyncManager>,
}
impl ConfigManager {
pub async fn new(config_path: impl Into<PathBuf>) -> Result<Self> {
let config_path = config_path.into();
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))]
{
let secrets = SecretManager::new();
let encrypted_store =
EncryptedStore::new(&config_path).context("Failed to create encrypted store")?;
Ok(Self {
encrypted_store,
secrets,
sync_manager: None,
})
}
}
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();
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,
})
}
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)
}
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())
}
}
}
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)?;
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(())
}
pub async fn set_secret(&self, key: &str, value: &str) -> Result<()> {
self.secrets.set(key, value)?;
debug!("Set secret: {}", key);
Ok(())
}
pub async fn get_secret(&self, key: &str) -> Result<Option<String>> {
self.secrets.get(key)
}
pub async fn delete_secret(&self, key: &str) -> Result<()> {
self.secrets.delete(key)?;
debug!("Deleted secret: {}", key);
Ok(())
}
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)
}
}
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)
}
}
pub fn sync_status(&self) -> SyncStatus {
self.sync_manager
.as_ref()
.map(|m| m.status())
.unwrap_or(SyncStatus::Disabled)
}
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();
let config = manager.load().await.unwrap();
assert_eq!(config.runtime.mode, RuntimeMode::Local);
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();
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()));
manager.delete_secret("test.key").await.unwrap();
let value = manager.get_secret("test.key").await.unwrap();
assert_eq!(value, None);
}
}