use std::{
env,
path::{Path, PathBuf},
sync::OnceLock,
};
use color_eyre::eyre::{Result, WrapErr, eyre};
use directories::ProjectDirs;
use etcetera::{BaseStrategy, choose_base_strategy};
use tracing::warn;
pub mod document;
pub mod repo_entry;
pub use document::{Document, Warning};
const CONFIG_FILE: &str = "config.toml";
#[derive(Clone, Debug, Default)]
pub struct Config {
pub config_dir: PathBuf,
pub data_dir: PathBuf,
pub document: Document,
pub warnings: Vec<Warning>,
pub zero_config: bool,
}
impl Config {
pub fn new() -> Result<Self> {
let resolved = resolved_config();
check_named_paths_exist(&resolved.named)?;
Self::at(resolved.dir.clone(), resolved.file.clone())
}
pub fn at(config_dir: PathBuf, config_file: PathBuf) -> Result<Self> {
let loaded = document::load(&config_file)?;
for warning in &loaded.warnings {
warn!("{warning}");
}
Ok(Self {
config_dir,
data_dir: data_dir(),
document: loaded.document,
warnings: loaded.warnings,
zero_config: loaded.zero_config,
})
}
}
struct ResolvedConfig {
dir: PathBuf,
file: PathBuf,
named: NamedPaths,
}
#[derive(Clone, Debug, Default)]
pub struct NamedPaths {
pub env_dir: Option<PathBuf>,
pub flag_file: Option<PathBuf>,
}
fn resolve_config(
env_config_dir: Option<PathBuf>,
flag_config_file: Option<PathBuf>,
default_dir: PathBuf,
) -> ResolvedConfig {
let dir = env_config_dir.clone().unwrap_or(default_dir);
let file = flag_config_file
.clone()
.unwrap_or_else(|| dir.join(CONFIG_FILE));
ResolvedConfig {
dir,
file,
named: NamedPaths {
env_dir: env_config_dir,
flag_file: flag_config_file,
},
}
}
pub fn check_named_paths_exist(named: &NamedPaths) -> Result<()> {
if let Some(dir) = named.env_dir.as_ref().filter(|dir| !dir.exists()) {
return Err(eyre!("REPON_CONFIG `{}` does not exist", dir.display()));
}
if let Some(file) = named.flag_file.as_ref().filter(|file| !file.exists()) {
return Err(eyre!("--config `{}` does not exist", file.display()));
}
Ok(())
}
fn resolve_data(env_data_dir: Option<PathBuf>, default_dir: PathBuf) -> PathBuf {
env_data_dir.unwrap_or(default_dir)
}
fn default_config_dir() -> PathBuf {
choose_base_strategy()
.map_or_else(
|_| PathBuf::from(".config"),
|strategy| strategy.config_dir(),
)
.join(env!("CARGO_PKG_NAME"))
}
fn default_data_dir() -> PathBuf {
project_dirs().map_or_else(
|| PathBuf::from(".data"),
|dirs| dirs.data_local_dir().to_path_buf(),
)
}
fn project_dirs() -> Option<ProjectDirs> {
ProjectDirs::from("", "", env!("CARGO_PKG_NAME"))
}
static CONFIG: OnceLock<ResolvedConfig> = OnceLock::new();
static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
pub fn init(flag_config_file: Option<PathBuf>) {
CONFIG.get_or_init(|| resolve_config_from_env(flag_config_file));
}
fn resolved_config() -> &'static ResolvedConfig {
CONFIG.get_or_init(|| resolve_config_from_env(None))
}
fn resolve_config_from_env(flag_config_file: Option<PathBuf>) -> ResolvedConfig {
resolve_config(
env::var_os("REPON_CONFIG").map(PathBuf::from),
flag_config_file,
default_config_dir(),
)
}
pub fn config_dir() -> PathBuf {
resolved_config().dir.clone()
}
pub fn config_file() -> PathBuf {
resolved_config().file.clone()
}
pub fn named_paths() -> NamedPaths {
resolved_config().named.clone()
}
pub fn themes_dir() -> PathBuf {
themes_dir_under(&config_dir())
}
fn themes_dir_under(config_dir: &Path) -> PathBuf {
config_dir.join("themes")
}
pub fn write_edited(path: &Path, contents: &str) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).wrap_err("create the config directory")?;
}
std::fs::write(path, contents).wrap_err("write config.toml")?;
Ok(())
}
pub fn data_dir() -> PathBuf {
DATA_DIR
.get_or_init(|| {
resolve_data(
env::var_os("REPON_DATA").map(PathBuf::from),
default_data_dir(),
)
})
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_file_sits_under_the_default_config_dir() {
let resolved = resolve_config(None, None, PathBuf::from("/default/config"));
assert_eq!(resolved.dir, PathBuf::from("/default/config"));
assert_eq!(resolved.file, PathBuf::from("/default/config/config.toml"));
}
#[test]
fn env_override_beats_the_default_config_dir() {
let resolved = resolve_config(
Some(PathBuf::from("/env/config")),
None,
PathBuf::from("/default/config"),
);
assert_eq!(resolved.dir, PathBuf::from("/env/config"));
assert_eq!(resolved.file, PathBuf::from("/env/config/config.toml"));
}
#[test]
fn flag_config_file_beats_the_env_override() {
let resolved = resolve_config(
Some(PathBuf::from("/env/config")),
Some(PathBuf::from("/flag/custom.toml")),
PathBuf::from("/default/config"),
);
assert_eq!(resolved.file, PathBuf::from("/flag/custom.toml"));
assert_eq!(resolved.dir, PathBuf::from("/env/config"));
}
fn resolved(
dir: PathBuf,
file: PathBuf,
env_dir: Option<PathBuf>,
flag_file: Option<PathBuf>,
) -> ResolvedConfig {
ResolvedConfig {
dir,
file,
named: NamedPaths { env_dir, flag_file },
}
}
#[test]
fn a_repon_config_directory_that_does_not_exist_is_an_error_naming_the_variable_and_value() {
let missing = tempfile::tempdir()
.expect("temp dir")
.path()
.join("does-not-exist");
let resolved = resolved(
missing.clone(),
missing.join(CONFIG_FILE),
Some(missing),
None,
);
let err = check_named_paths_exist(&resolved.named)
.expect_err("a missing REPON_CONFIG directory must be an error");
let message = err.to_string();
assert!(
message.contains("REPON_CONFIG"),
"expected the variable named in the message, got: {message:?}"
);
assert!(
message.contains("does-not-exist"),
"expected the offending path named in the message, got: {message:?}"
);
}
#[test]
fn a_repon_config_directory_that_exists_with_no_config_toml_inside_passes() {
let dir = tempfile::tempdir().expect("temp dir");
let resolved = resolved(
dir.path().to_path_buf(),
dir.path().join(CONFIG_FILE),
Some(dir.path().to_path_buf()),
None,
);
check_named_paths_exist(&resolved.named)
.expect("an existing REPON_CONFIG directory with no config.toml must not error");
}
#[test]
fn a_flag_config_file_that_does_not_exist_is_an_error_naming_the_flag_and_value() {
let dir = tempfile::tempdir().expect("temp dir");
let missing = dir.path().join("missing.toml");
let resolved = resolved(
dir.path().to_path_buf(),
missing.clone(),
None,
Some(missing),
);
let err = check_named_paths_exist(&resolved.named)
.expect_err("a missing --config file must be an error");
let message = err.to_string();
assert!(
message.contains("--config"),
"expected the flag named in the message, got: {message:?}"
);
assert!(
message.contains("missing.toml"),
"expected the offending path named in the message, got: {message:?}"
);
}
#[test]
fn a_flag_config_file_that_exists_passes() {
let dir = tempfile::tempdir().expect("temp dir");
let file = dir.path().join("config.toml");
std::fs::write(&file, "").expect("write an empty config file");
let resolved = resolved(dir.path().to_path_buf(), file.clone(), None, Some(file));
check_named_paths_exist(&resolved.named).expect("an existing --config file must not error");
}
#[test]
fn neither_env_nor_flag_given_never_errors_even_when_the_default_path_is_absent() {
let missing_default = tempfile::tempdir()
.expect("temp dir")
.path()
.join("nowhere");
let resolved = resolved(
missing_default.clone(),
missing_default.join(CONFIG_FILE),
None,
None,
);
check_named_paths_exist(&resolved.named)
.expect("the default path's own absence must never be an error");
}
#[test]
fn env_data_override_beats_the_default_data_dir() {
let resolved = resolve_data(
Some(PathBuf::from("/env/data")),
PathBuf::from("/default/data"),
);
assert_eq!(resolved, PathBuf::from("/env/data"));
}
#[test]
fn the_two_halves_resolve_independently_and_precedence_holds() {
let config = resolve_config(
Some(PathBuf::from("/env/config")),
Some(PathBuf::from("/flag/custom.toml")),
PathBuf::from("/default/config"),
);
let data = resolve_data(
Some(PathBuf::from("/env/data")),
PathBuf::from("/default/data"),
);
assert_eq!(config.file, PathBuf::from("/flag/custom.toml"));
assert_eq!(data, PathBuf::from("/env/data"));
let config_only = resolve_config(None, None, PathBuf::from("/default/config"));
assert_eq!(config_only.dir, PathBuf::from("/default/config"));
}
#[test]
fn the_default_config_dir_is_named_for_the_package_regardless_of_platform() {
assert!(default_config_dir().ends_with(env!("CARGO_PKG_NAME")));
}
#[test]
fn the_default_config_dir_does_not_follow_the_platform_convention_the_directories_crate_would_give()
{
let config_dir = default_config_dir();
assert!(
!config_dir.to_string_lossy().contains("Application Support"),
"config dir (and so themes/) must not follow the macOS Application Support \
convention, got {config_dir:?}"
);
}
fn function_source(source: &str, signature: &str) -> String {
let lines: Vec<&str> = source.lines().collect();
let fn_line = lines
.iter()
.position(|line| line.contains(signature))
.unwrap_or_else(|| panic!("no `{signature}` in source"));
let mut start = fn_line;
while start > 0 && lines[start - 1].trim_start().starts_with('#') {
start -= 1;
}
let mut end = fn_line + 1;
while end < lines.len() && !lines[end].starts_with("fn ") {
end += 1;
}
lines[start..end].join("\n")
}
#[test]
fn default_config_dir_contains_no_platform_specific_branch() {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let source =
crate::test_support::production_source_at(&manifest_dir.join("src/config/mod.rs"));
let body = function_source(&source, "fn default_config_dir");
let needles = [
"cfg(target_os",
"cfg(windows",
"cfg(unix",
"cfg(target_family",
];
for needle in needles {
assert!(
!body.contains(needle),
"default_config_dir must resolve identically on every host, found `{needle}` \
in its body: {body}"
);
}
}
#[test]
fn theme_files_live_in_a_themes_directory_beside_config_toml() {
assert_eq!(
themes_dir_under(Path::new("/default/config")),
PathBuf::from("/default/config/themes")
);
}
}