use std::collections::BTreeMap;
use std::path::Path;
use jsonc_parser::ParseOptions;
use serde_json::Value;
use crate::config::resolve;
use crate::config::schema::{Config, ConfigError};
use crate::config::validate::validate_config;
pub type Environment = BTreeMap<String, String>;
pub const CONFIG_VARIABLE: &str = "ERRAND_CONFIG";
pub const CONFIG_DIRECTORY: &str = "errand";
pub const CONFIG_FILENAME: &str = "config.json";
pub const CONFIG_FILENAME_JSONC: &str = "config.jsonc";
const CONFIG_BASENAMES: [&str; 2] = [CONFIG_FILENAME, CONFIG_FILENAME_JSONC];
pub const RULES_FILENAME: &str = "AGENTS.md";
pub fn config_candidates(env: &Environment) -> Vec<String> {
if let Some(named) = env
.get(CONFIG_VARIABLE)
.map(|value| value.trim())
.filter(|named| !named.is_empty())
{
return vec![named.to_owned()];
}
let home = env.get("HOME").map_or("", |value| value.trim());
let xdg = env
.get("XDG_CONFIG_HOME")
.map(|value| value.trim())
.filter(|named| !named.is_empty());
let root = match xdg {
Some(xdg) => std::path::PathBuf::from(xdg),
None => std::path::PathBuf::from(home).join(".config"),
};
let mut candidates = Vec::new();
for dir in [
root.join(CONFIG_DIRECTORY),
std::path::PathBuf::from("/etc").join(CONFIG_DIRECTORY),
] {
for name in CONFIG_BASENAMES {
candidates.push(dir.join(name).to_string_lossy().into_owned());
}
}
for name in CONFIG_BASENAMES {
candidates.push(name.to_owned());
}
candidates
}
pub fn config_path(env: &Environment, exists: impl Fn(&str) -> bool) -> String {
let candidates = config_candidates(env);
candidates
.iter()
.find(|candidate| exists(candidate))
.cloned()
.unwrap_or_else(|| candidates[0].clone())
}
pub fn load_config(
path: &str,
read: impl Fn(&str) -> std::io::Result<String>,
env: &Environment,
exists: impl Fn(&str) -> bool,
) -> Result<Config, ConfigError> {
let text = match read(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let looked = config_candidates(env);
return Err(ConfigError {
problems: {
let mut problems = vec![format!("there is no configuration file at {path}")];
if looked.len() > 1 {
problems.push(format!("looked in: {}", looked.join(", ")));
}
problems.push(format!(
"write one there, or name it with {CONFIG_VARIABLE}"
));
problems
},
});
}
Err(error) => {
return Err(ConfigError {
problems: vec![format!(
"the configuration file at {path} could not be read: {error}"
)],
});
}
};
let parsed = jsonc_parser::parse_to_serde_value::<Value>(
&text,
&ParseOptions {
allow_trailing_commas: true,
..ParseOptions::default()
},
);
let parsed = match parsed {
Ok(parsed) => parsed,
Err(error) => {
return Err(ConfigError {
problems: vec![format!(
"the configuration file at {path} is not valid JSON or JSONC: {error}"
)],
});
}
};
let config = validate_config(&parsed)?;
match config.agent.rules_path {
Some(_) => Ok(config),
None => Ok(with_rules_beside(config, path, &exists)),
}
}
fn with_rules_beside(config: Config, path: &str, exists: &impl Fn(&str) -> bool) -> Config {
let dir = Path::new(path).parent().unwrap_or(Path::new("/"));
let candidate = resolve(&dir.join(RULES_FILENAME).to_string_lossy());
if exists(&candidate) {
let mut config = config;
config.agent.rules_path = Some(candidate);
return config;
}
config
}
pub fn file_exists(path: &str) -> bool {
std::fs::metadata(path).is_ok_and(|meta| meta.is_file())
}
#[cfg(test)]
mod tests;