use anyhow::Result;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
pub fn resolve_config_file(filename: &str, env_var: &str) -> Option<PathBuf> {
if let Ok(path) = std::env::var(env_var) {
return Some(PathBuf::from(path));
}
let cwd = PathBuf::from(filename);
if cwd.exists() {
return Some(cwd);
}
let home = enact_home().join(filename);
if home.exists() {
return Some(home);
}
None
}
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")
})
}
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(())
}
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")
}
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(())
}
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(())
}
const CONFIG_FILES: &[&str] = &[
"channels.yaml",
"config.yaml",
"tools.yaml",
"skills.yaml",
"memory.yaml",
"a2a.yaml",
"mcp.yaml",
"providers.yaml",
"context.yaml",
"cron.yaml",
];
pub fn create_config_backup() -> Result<()> {
let home = enact_home();
let backups_dir = home.join("backups");
std::fs::create_dir_all(&backups_dir)?;
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(())
}
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());
}
}