use anyhow::{Context, Result};
use config::{Config, File, FileFormat};
use serde_json::{Map, Value};
use std::path::{Path, PathBuf};
const PROJECT_FILE_NAME: &str = ".enwiro.toml";
const USER_CONFIG_SUBDIR: &str = ".config/enwiro";
pub struct ConfigLoader {
home: PathBuf,
}
impl ConfigLoader {
pub fn from_env() -> Result<Self> {
let home = home::home_dir().context("Could not determine user home directory")?;
Ok(Self { home })
}
pub fn with_home(home: impl Into<PathBuf>) -> Self {
Self { home: home.into() }
}
pub fn user_config_path(&self, scope: &str) -> PathBuf {
self.home
.join(USER_CONFIG_SUBDIR)
.join(format!("{scope}.toml"))
}
pub fn load_user_config(&self, scope: &str) -> Result<Value> {
let path = self.user_config_path(scope);
if !path.exists() {
return Ok(Value::Object(Map::new()));
}
let cfg = Config::builder()
.add_source(File::from(path.as_path()).format(FileFormat::Toml))
.build()
.with_context(|| format!("Failed to load user config at {}", path.display()))?;
cfg.try_deserialize::<Value>()
.with_context(|| format!("Failed to deserialize user config at {}", path.display()))
}
pub fn build_cookbook_config(
&self,
cwd: &Path,
scope: &str,
allowlist: &[&str],
) -> Result<Value> {
let mut builder = Config::builder();
let user_path = self.user_config_path(scope);
if user_path.exists() {
builder = builder.add_source(File::from(user_path.as_path()).format(FileFormat::Toml));
}
for path in collect_project_files(cwd) {
let Some(filtered) = filter_project_layer(&path, scope, allowlist) else {
continue;
};
builder = builder.add_source(File::from_str(&filtered, FileFormat::Toml));
}
let cfg = builder.build().context("Failed to merge config layers")?;
cfg.try_deserialize::<Value>()
.context("Failed to deserialize merged config")
}
}
pub fn build_cookbook_config(cwd: &Path, scope: &str, allowlist: &[&str]) -> Result<Value> {
ConfigLoader::from_env()?.build_cookbook_config(cwd, scope, allowlist)
}
pub fn load_user_config(scope: &str) -> Result<Value> {
ConfigLoader::from_env()?.load_user_config(scope)
}
pub fn user_config_path(scope: &str) -> Result<PathBuf> {
Ok(ConfigLoader::from_env()?.user_config_path(scope))
}
fn collect_project_files(cwd: &Path) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = cwd
.ancestors()
.map(|dir| dir.join(PROJECT_FILE_NAME))
.filter(|p| p.is_file())
.collect();
files.reverse();
files
}
fn filter_project_layer(path: &Path, scope: &str, allowlist: &[&str]) -> Option<String> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
tracing::debug!(path = %path.display(), error = %e, "Failed to read project config; skipping");
return None;
}
};
let parsed: toml::Table = match toml::from_str(&text) {
Ok(v) => v,
Err(e) => {
tracing::debug!(path = %path.display(), error = %e, "Malformed TOML in project config; skipping");
return None;
}
};
let section = parsed.get(scope)?.as_table().or_else(|| {
tracing::debug!(
path = %path.display(),
scope,
"Project config section is not a TOML table; skipping"
);
None
})?;
let mut kept = toml::Table::new();
for (k, v) in section {
if allowlist.contains(&k.as_str()) {
kept.insert(k.clone(), v.clone());
} else {
tracing::debug!(
path = %path.display(),
scope,
key = %k,
"Dropping non-allowlisted project config key"
);
}
}
toml::to_string(&kept).ok().or_else(|| {
tracing::debug!(path = %path.display(), "Failed to re-serialize filtered TOML; skipping");
None
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::fs;
use tempfile::TempDir;
struct Env {
home: TempDir,
cwd: TempDir,
}
impl Env {
fn new() -> Self {
Self {
home: tempfile::tempdir().expect("home tempdir"),
cwd: tempfile::tempdir().expect("cwd tempdir"),
}
}
fn loader(&self) -> ConfigLoader {
ConfigLoader::with_home(self.home.path())
}
fn write_user(&self, scope: &str, body: &str) {
let dir = self.home.path().join(USER_CONFIG_SUBDIR);
fs::create_dir_all(&dir).expect("mkdir user config dir");
fs::write(dir.join(format!("{scope}.toml")), body).expect("write user config");
}
fn write_project(&self, dir: &Path, body: &str) {
fs::create_dir_all(dir).expect("mkdir project dir");
fs::write(dir.join(PROJECT_FILE_NAME), body).expect("write project config");
}
}
#[test]
fn user_config_path_matches_confy_layout() {
let env = Env::new();
let p = env.loader().user_config_path("cookbook-git");
assert_eq!(p, env.home.path().join(".config/enwiro/cookbook-git.toml"));
}
#[test]
fn load_user_config_returns_empty_when_missing() {
let env = Env::new();
let v = env
.loader()
.load_user_config("cookbook-git")
.expect("missing file is not an error");
assert_eq!(v, json!({}));
}
#[test]
fn load_user_config_parses_valid_toml() {
let env = Env::new();
env.write_user("cookbook-git", "repo_globs = [\"a\", \"b\"]\n");
let v = env.loader().load_user_config("cookbook-git").unwrap();
assert_eq!(v, json!({ "repo_globs": ["a", "b"] }));
}
#[test]
fn load_user_config_fails_loud_on_malformed_toml() {
let env = Env::new();
env.write_user("cookbook-git", "this is not = = valid toml");
let err = env
.loader()
.load_user_config("cookbook-git")
.expect_err("malformed user TOML must propagate");
let msg = format!("{err:#}");
assert!(
msg.contains("user config"),
"error should mention user config path; got: {msg}"
);
}
#[test]
fn no_project_files_yields_user_only() {
let env = Env::new();
env.write_user("cookbook-git", "repo_globs = [\"u\"]\n");
let v = env
.loader()
.build_cookbook_config(env.cwd.path(), "cookbook-git", &["repo_globs"])
.unwrap();
assert_eq!(v, json!({ "repo_globs": ["u"] }));
}
#[test]
fn project_layer_wins_for_allowlisted_keys() {
let env = Env::new();
env.write_user("cookbook-git", "repo_globs = [\"u\"]\n");
env.write_project(env.cwd.path(), "[cookbook-git]\nrepo_globs = [\"p\"]\n");
let v = env
.loader()
.build_cookbook_config(env.cwd.path(), "cookbook-git", &["repo_globs"])
.unwrap();
assert_eq!(v, json!({ "repo_globs": ["p"] }));
}
#[test]
fn innermost_project_layer_wins_over_outer() {
let env = Env::new();
let inner = env.cwd.path().join("packages/a");
env.write_project(env.cwd.path(), "[cookbook-git]\nrepo_globs = [\"outer\"]\n");
env.write_project(&inner, "[cookbook-git]\nrepo_globs = [\"inner\"]\n");
let v = env
.loader()
.build_cookbook_config(&inner, "cookbook-git", &["repo_globs"])
.unwrap();
assert_eq!(v, json!({ "repo_globs": ["inner"] }));
}
#[test]
fn non_allowlisted_keys_dropped() {
let env = Env::new();
env.write_user("cookbook-git", "repo_globs = [\"u\"]\n");
env.write_project(
env.cwd.path(),
"[cookbook-git]\nrepo_globs = [\"p\"]\nworkspaces_directory = \"/hostile\"\n",
);
let v = env
.loader()
.build_cookbook_config(env.cwd.path(), "cookbook-git", &["repo_globs"])
.unwrap();
assert_eq!(v, json!({ "repo_globs": ["p"] }));
}
#[test]
fn empty_allowlist_makes_project_file_a_noop() {
let env = Env::new();
env.write_user("cookbook-git", "repo_globs = [\"u\"]\n");
env.write_project(env.cwd.path(), "[cookbook-git]\nrepo_globs = [\"p\"]\n");
let v = env
.loader()
.build_cookbook_config(env.cwd.path(), "cookbook-git", &[])
.unwrap();
assert_eq!(v, json!({ "repo_globs": ["u"] }));
}
#[test]
fn malformed_project_layer_logged_and_skipped_other_layers_keep_working() {
let env = Env::new();
let inner = env.cwd.path().join("packages/a");
env.write_user("cookbook-git", "repo_globs = [\"u\"]\n");
env.write_project(env.cwd.path(), "this is = = not valid toml");
env.write_project(&inner, "[cookbook-git]\nrepo_globs = [\"inner\"]\n");
let v = env
.loader()
.build_cookbook_config(&inner, "cookbook-git", &["repo_globs"])
.unwrap();
assert_eq!(v, json!({ "repo_globs": ["inner"] }));
}
#[test]
fn missing_scope_section_is_silent_noop() {
let env = Env::new();
env.write_user("cookbook-git", "repo_globs = [\"u\"]\n");
env.write_project(env.cwd.path(), "[cookbook-github]\nsomething = \"x\"\n");
let v = env
.loader()
.build_cookbook_config(env.cwd.path(), "cookbook-git", &["repo_globs"])
.unwrap();
assert_eq!(v, json!({ "repo_globs": ["u"] }));
}
#[test]
fn build_cookbook_config_returns_empty_when_no_files_anywhere() {
let env = Env::new();
let v = env
.loader()
.build_cookbook_config(env.cwd.path(), "missing-scope", &["x"])
.expect("no-files build_cookbook_config must succeed");
assert_eq!(
v,
json!({}),
"with no user file and no project file, the loader must return an empty JSON object so cookbooks with #[serde(default)] structs can deserialize to defaults"
);
}
#[test]
fn nested_table_keys_merge_deeply() {
let env = Env::new();
env.write_user("cookbook-git", "[settings]\nfoo = 1\nbar = 2\n");
env.write_project(env.cwd.path(), "[cookbook-git.settings]\nbar = 99\n");
let v = env
.loader()
.build_cookbook_config(env.cwd.path(), "cookbook-git", &["settings"])
.unwrap();
assert_eq!(v, json!({ "settings": { "foo": 1, "bar": 99 } }));
}
}