use std::path::{Path, PathBuf};
use crate::domain::config::{self, FileConfig};
use crate::errors::OneharnessError;
const PROJECT_FILE_NAMES: &[&str] = &["oneharness.toml", ".oneharness.toml"];
const NO_CONFIG_ENV: &str = "ONEHARNESS_NO_CONFIG";
const USER_CONFIG_ENV: &str = "ONEHARNESS_CONFIG";
#[derive(Debug, Default)]
pub struct LoadedConfig {
pub config: FileConfig,
pub files: Vec<String>,
}
pub fn load(
explicit: Option<&Path>,
no_config: bool,
project_start: &Path,
) -> Result<LoadedConfig, OneharnessError> {
let mut loaded = LoadedConfig::default();
for (path, layer) in load_layers(explicit, no_config, project_start)? {
loaded.config = config::merge(loaded.config, layer);
loaded.files.push(path);
}
Ok(loaded)
}
pub fn load_layers(
explicit: Option<&Path>,
no_config: bool,
project_start: &Path,
) -> Result<Vec<(String, FileConfig)>, OneharnessError> {
if no_config || env_flag(NO_CONFIG_ENV) {
return Ok(Vec::new());
}
let mut layers = Vec::new();
if let Some(path) = explicit {
layers.push((path.display().to_string(), read_required(path)?));
} else {
if let Some(path) = user_config_path()? {
if let Some(user) = read_optional(&path)? {
layers.push((path.display().to_string(), user));
}
}
if let Some(path) = find_project_file(project_start) {
if let Some(project) = read_optional(&path)? {
layers.push((path.display().to_string(), project));
}
}
}
if let Some(env) = config::from_env(|name| std::env::var(name).ok())
.map_err(OneharnessError::EnvConfigInvalid)?
{
layers.push((config::ENV_SOURCE.to_string(), env));
}
Ok(layers)
}
fn env_flag(key: &str) -> bool {
match std::env::var(key) {
Ok(v) => !matches!(v.as_str(), "" | "0" | "false"),
Err(_) => false,
}
}
fn user_config_path() -> Result<Option<PathBuf>, OneharnessError> {
if let Ok(value) = std::env::var(USER_CONFIG_ENV) {
if !value.is_empty() {
let path = PathBuf::from(&value);
if !path.is_file() {
return Err(OneharnessError::ConfigInvalid {
path: value,
message: format!("{USER_CONFIG_ENV} points at a file that does not exist"),
});
}
return Ok(Some(path));
}
}
Ok(platform_config_dir().map(|d| d.join("oneharness").join("config.toml")))
}
fn platform_config_dir() -> Option<PathBuf> {
if cfg!(windows) {
return std::env::var_os("APPDATA")
.filter(|v| !v.is_empty())
.map(PathBuf::from);
}
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
return Some(PathBuf::from(xdg));
}
std::env::var_os("HOME")
.filter(|v| !v.is_empty())
.map(|home| PathBuf::from(home).join(".config"))
}
fn find_project_file(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(d) = dir {
for name in PROJECT_FILE_NAMES {
let candidate = d.join(name);
if candidate.is_file() {
return Some(candidate);
}
}
dir = d.parent();
}
None
}
fn read_optional(path: &Path) -> Result<Option<FileConfig>, OneharnessError> {
match std::fs::read_to_string(path) {
Ok(text) => parse_at(path, &text).map(Some),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(OneharnessError::ConfigRead {
path: path.display().to_string(),
source,
}),
}
}
fn read_required(path: &Path) -> Result<FileConfig, OneharnessError> {
let text = std::fs::read_to_string(path).map_err(|source| OneharnessError::ConfigRead {
path: path.display().to_string(),
source,
})?;
parse_at(path, &text)
}
fn parse_at(path: &Path, text: &str) -> Result<FileConfig, OneharnessError> {
config::parse(text).map_err(|message| OneharnessError::ConfigInvalid {
path: path.display().to_string(),
message,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("oneharness-cfg-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn project_file_is_found_walking_up() {
let root = temp_dir("walk");
let nested = root.join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(root.join("oneharness.toml"), "model = \"outer\"").unwrap();
let found = find_project_file(&nested).unwrap();
assert_eq!(found, root.join("oneharness.toml"));
std::fs::write(nested.join(".oneharness.toml"), "model = \"inner\"").unwrap();
let found = find_project_file(&nested).unwrap();
assert_eq!(found, nested.join(".oneharness.toml"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn missing_discovered_file_is_an_absent_layer() {
let dir = temp_dir("missing");
assert!(read_optional(&dir.join("oneharness.toml"))
.unwrap()
.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn invalid_file_carries_its_path_in_the_error() {
let dir = temp_dir("invalid");
let path = dir.join("oneharness.toml");
std::fs::write(&path, "not = valid = toml").unwrap();
let err = read_optional(&path).unwrap_err();
assert!(err.to_string().contains("oneharness.toml"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
}