enact-config 0.0.2

Unified configuration management for Enact - secure storage with keychain and encrypted files
Documentation
//! ENACT_HOME — single source of truth for the Enact data directory.
//!
//! All crates should resolve the data directory via `enact_home()`.
//! Default: `~/.enact`. Override with `ENACT_HOME` env var.

use anyhow::Result;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};

/// Resolve a named config file via: env_var → cwd/filename → ENACT_HOME/filename → None.
///
/// This is the standard resolution chain for all per-crate config files (e.g., channels.yaml,
/// tools.yaml). If the environment variable is set, its value is returned as-is. Otherwise,
/// checks cwd then ENACT_HOME. Returns None if the file doesn't exist in any location.
///
/// # Arguments
///
/// * `filename` - The config filename (e.g., "channels.yaml")
/// * `env_var` - The environment variable name to check first (e.g., "ENACT_CHANNELS_CONFIG_PATH")
///
/// # Example
///
/// ```rust,no_run
/// use enact_config::resolve_config_file;
///
/// // Checks ENACT_CHANNELS_CONFIG_PATH, then ./channels.yaml, then ~/.enact/channels.yaml
/// if let Some(path) = resolve_config_file("channels.yaml", "ENACT_CHANNELS_CONFIG_PATH") {
///     println!("Found config at: {:?}", path);
/// }
/// ```
pub fn resolve_config_file(filename: &str, env_var: &str) -> Option<PathBuf> {
    // First, check if the environment variable is set
    if let Ok(path) = std::env::var(env_var) {
        return Some(PathBuf::from(path));
    }

    // Second, check cwd
    let cwd = PathBuf::from(filename);
    if cwd.exists() {
        return Some(cwd);
    }

    // Third, check ENACT_HOME
    let home = enact_home().join(filename);
    if home.exists() {
        return Some(home);
    }

    None
}

/// Returns the Enact home directory.
///
/// Resolution order:
/// 1. `ENACT_HOME` environment variable (if set)
/// 2. `$HOME/.enact` (or platform equivalent)
/// 3. `.` if home cannot be determined
pub fn enact_home() -> PathBuf {
    std::env::var("ENACT_HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|_| {
            dirs::home_dir()
                .unwrap_or_else(|| PathBuf::from("."))
                .join(".enact")
        })
}

/// Ensures the standard ENACT_HOME directory structure exists.
///
/// Creates: `agents/`, `projects/`, `state/`, `logs/`, `commands/`, `plugins/`, `skills/`.
/// Per-agent and per-project subdirs are created when an agent or project is first used.
pub fn ensure_home_dirs(home: &Path) -> Result<()> {
    for sub in &[
        "agents", "projects", "state", "logs", "commands", "plugins", "skills",
    ] {
        std::fs::create_dir_all(home.join(sub))?;
    }
    Ok(())
}

/// Load ENACT.md context from enact_home and optional project .enact/ENACT.md.
/// Returns concatenated content (global first, then project) for injection into system prompt.
pub fn load_enact_md_context(project_dir: Option<&Path>) -> String {
    let home = enact_home();
    let mut parts = Vec::new();
    let global = home.join("ENACT.md");
    if global.exists() {
        if let Ok(s) = std::fs::read_to_string(&global) {
            let t = s.trim();
            if !t.is_empty() {
                parts.push(t.to_string());
            }
        }
    }
    if let Some(proj) = project_dir {
        let project_md = proj.join(".enact").join("ENACT.md");
        if project_md.exists() {
            if let Ok(s) = std::fs::read_to_string(&project_md) {
                let t = s.trim();
                if !t.is_empty() {
                    parts.push(t.to_string());
                }
            }
        }
    }
    parts.join("\n\n")
}

/// Load environment variables from `~/.enact/.env` (ENACT_HOME).
/// Call this at CLI/server startup so secrets are available from ~/.enact/.env by default.
/// Silently no-ops if the file does not exist.
pub fn load_dotenv_from_home() -> Result<()> {
    let path = enact_home().join(".env");
    if path.exists() {
        dotenv::from_path(&path)
            .map_err(|e| anyhow::anyhow!("Failed to load {:?}: {}", path, e))?;
    }
    Ok(())
}

/// Write or update a single key in `~/.enact/.env`.
/// Creates the file and parent directory if needed. Replaces existing KEY= line or appends.
pub fn write_env_secret(key: &str, value: &str) -> Result<()> {
    let home = enact_home();
    std::fs::create_dir_all(&home)?;
    let path = home.join(".env");

    let mut lines: Vec<String> = if path.exists() {
        BufReader::new(std::fs::File::open(&path)?)
            .lines()
            .map_while(Result::ok)
            .collect()
    } else {
        Vec::new()
    };

    let prefix = format!("{}=", key);
    let value_escaped = if value.contains('\n') || value.contains('"') || value.contains('#') {
        format!(
            "\"{}\"",
            value
                .replace('\\', "\\\\")
                .replace('"', "\\\"")
                .replace('\n', "\\n")
        )
    } else {
        value.to_string()
    };
    let new_line = format!("{}={}", key, value_escaped);
    let mut found = false;
    for line in lines.iter_mut() {
        if line.starts_with(&prefix) {
            *line = new_line.clone();
            found = true;
            break;
        }
    }
    if !found {
        lines.push(new_line);
    }

    let mut f = std::fs::File::create(&path)?;
    for line in &lines {
        writeln!(f, "{}", line)?;
    }
    f.sync_all()?;
    Ok(())
}

/// Config filenames included in rolling backups (all under ~/.enact/).
const CONFIG_FILES: &[&str] = &[
    "channels.yaml",
    "config.yaml",
    "tools.yaml",
    "skills.yaml",
    "memory.yaml",
    "a2a.yaml",
    "mcp.yaml",
    "providers.yaml",
    "context.yaml",
    "cron.yaml",
];

/// Create a rolling backup of all config YAML files under ~/.enact/backups/.
/// Keeps 4 slots: backup_1 (newest) .. backup_4 (oldest). On each call, shifts
/// existing slots and writes current state into backup_1. Call before any
/// config write so backup_1 is the state before the write (one undo = restore backup_1).
pub fn create_config_backup() -> Result<()> {
    let home = enact_home();
    let backups_dir = home.join("backups");
    std::fs::create_dir_all(&backups_dir)?;

    // Rotate: remove backup_4, then backup_3 -> backup_4, backup_2 -> backup_3, backup_1 -> backup_2
    let b1 = backups_dir.join("backup_1");
    let b2 = backups_dir.join("backup_2");
    let b3 = backups_dir.join("backup_3");
    let b4 = backups_dir.join("backup_4");
    if b4.exists() {
        std::fs::remove_dir_all(&b4)?;
    }
    if b3.exists() {
        std::fs::rename(&b3, &b4)?;
    }
    if b2.exists() {
        std::fs::rename(&b2, &b3)?;
    }
    if b1.exists() {
        std::fs::rename(&b1, &b2)?;
    }

    std::fs::create_dir_all(&b1)?;
    for name in CONFIG_FILES {
        let src = home.join(name);
        if src.exists() {
            std::fs::copy(&src, b1.join(name))?;
        }
    }
    Ok(())
}

/// Write a value as YAML to `~/.enact/<filename>`. Creates parent directory if needed.
/// Used by CLI to persist config edits to the default location only.
/// Creates a rolling backup of all config files before writing.
pub fn write_yaml_at_home<T: serde::Serialize>(filename: &str, value: &T) -> Result<()> {
    create_config_backup()?;
    let path = enact_home().join(filename);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let s = serde_yaml::to_string(value)?;
    std::fs::write(&path, s)?;
    Ok(())
}

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

    #[test]
    fn enact_home_returns_path() {
        let home = enact_home();
        assert!(!home.as_os_str().is_empty());
    }

    #[test]
    fn ensure_home_dirs_creates_dirs() {
        let temp = tempfile::tempdir().unwrap();
        ensure_home_dirs(temp.path()).unwrap();
        for sub in &[
            "agents", "projects", "state", "logs", "commands", "plugins", "skills",
        ] {
            assert!(temp.path().join(sub).exists(), "missing {}", sub);
        }
    }

    #[test]
    fn resolve_config_file_env_var() {
        let temp = tempfile::tempdir().unwrap();
        let path = temp.path().join("test.yaml");
        std::fs::write(&path, "test: true").unwrap();
        std::env::set_var("ENACT_TEST_CONFIG_PATH", path.to_str().unwrap());
        let result = resolve_config_file("nonexistent.yaml", "ENACT_TEST_CONFIG_PATH");
        std::env::remove_var("ENACT_TEST_CONFIG_PATH");
        assert_eq!(result, Some(path));
    }

    #[test]
    fn resolve_config_file_not_found() {
        let result = resolve_config_file(
            "nonexistent_config_12345.yaml",
            "ENACT_NONEXISTENT_CONFIG_PATH",
        );
        assert!(result.is_none());
    }
}