use anyhow::{bail, Result};
use camino::Utf8PathBuf;
#[derive(Debug, serde::Serialize)]
pub struct ResolvedConfig {
pub store_root: Utf8PathBuf,
pub log_dir: Utf8PathBuf,
pub log_path: Utf8PathBuf,
pub config_dir: Utf8PathBuf,
pub config_path: Utf8PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unpaywall_email: Option<String>,
}
impl ResolvedConfig {
pub fn from_env() -> Result<Self> {
let home =
Utf8PathBuf::try_from(dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?)?;
let cfg = Utf8PathBuf::try_from(
dirs::config_dir().ok_or_else(|| anyhow::anyhow!("no config dir"))?,
)?;
let store_root = std::env::var("DOIGET_STORE_ROOT")
.map(Utf8PathBuf::from)
.unwrap_or_else(|_| home.join("papers"));
let log_dir = std::env::var("DOIGET_LOG_DIR")
.map(Utf8PathBuf::from)
.unwrap_or_else(|_| cfg.join("doiget"));
let log_path = log_dir.join("access.jsonl");
let config_dir = cfg.join("doiget");
let config_path = config_dir.join("config.toml");
Ok(Self {
store_root,
log_dir,
log_path,
config_dir,
config_path,
contact_email: std::env::var("DOIGET_CONTACT_EMAIL").ok(),
unpaywall_email: std::env::var("DOIGET_UNPAYWALL_EMAIL").ok(),
})
}
}
#[allow(clippy::print_stdout, clippy::print_stderr)]
pub fn run(action: String) -> Result<()> {
let cfg = ResolvedConfig::from_env()?;
match action.as_str() {
"show" => {
let s = toml::to_string_pretty(&cfg)?;
print!("{s}");
}
"path" => {
println!("{}", cfg.config_path);
}
"doctor" => {
let mut all_ok = true;
check(
"store_root parent exists",
cfg.store_root.parent().map(|p| p.exists()).unwrap_or(true),
&mut all_ok,
);
check(
"log_dir parent exists",
cfg.log_dir.parent().map(|p| p.exists()).unwrap_or(true),
&mut all_ok,
);
check(
"contact_email set",
cfg.contact_email.is_some(),
&mut all_ok,
);
if !all_ok {
bail!("config doctor: one or more checks failed");
}
}
other => bail!("unknown config action: {other}; expected `show` / `path` / `doctor`"),
}
Ok(())
}
#[allow(clippy::print_stderr)]
fn check(label: &str, ok: bool, all_ok: &mut bool) {
let mark = if ok { "[ ok ]" } else { "[FAIL]" };
eprintln!("{mark} {label}");
if !ok {
*all_ok = false;
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
use super::*;
struct EnvGuard {
var: &'static str,
prior: Option<std::ffi::OsString>,
}
impl EnvGuard {
fn unset(var: &'static str) -> Self {
let prior = std::env::var_os(var);
std::env::remove_var(var);
EnvGuard { var, prior }
}
fn set(var: &'static str, value: &str) -> Self {
let prior = std::env::var_os(var);
std::env::set_var(var, value);
EnvGuard { var, prior }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.prior {
Some(v) => std::env::set_var(self.var, v),
None => std::env::remove_var(self.var),
}
}
}
fn unset_all_doiget_config_env() -> Vec<EnvGuard> {
[
"DOIGET_STORE_ROOT",
"DOIGET_LOG_DIR",
"DOIGET_CONTACT_EMAIL",
"DOIGET_UNPAYWALL_EMAIL",
]
.iter()
.map(|v| EnvGuard::unset(v))
.collect()
}
#[test]
#[serial_test::serial]
fn from_env_uses_home_default_when_unset() {
let _g = unset_all_doiget_config_env();
let cfg = ResolvedConfig::from_env().expect("home dir must resolve on test host");
assert!(
cfg.store_root.as_str().ends_with("papers"),
"store_root should fall back to <home>/papers when DOIGET_STORE_ROOT is unset; got {}",
cfg.store_root
);
assert_eq!(cfg.contact_email, None);
assert_eq!(cfg.unpaywall_email, None);
}
#[test]
#[serial_test::serial]
fn from_env_overrides_via_env() {
let _g = unset_all_doiget_config_env();
let _override = EnvGuard::set("DOIGET_STORE_ROOT", "/tmp/foo");
let cfg = ResolvedConfig::from_env().expect("home dir must resolve on test host");
assert_eq!(cfg.store_root.as_str(), "/tmp/foo");
}
#[test]
#[serial_test::serial]
fn doctor_fails_without_contact_email() {
let _g = unset_all_doiget_config_env();
let err = run("doctor".into())
.expect_err("doctor should fail when DOIGET_CONTACT_EMAIL is unset");
let msg = format!("{err}");
assert!(
msg.contains("config doctor"),
"unexpected error message: {msg}"
);
}
#[test]
#[serial_test::serial]
fn doctor_passes_with_contact_email() {
let _g = unset_all_doiget_config_env();
let _email = EnvGuard::set("DOIGET_CONTACT_EMAIL", "alice@example.org");
run("doctor".into()).expect("doctor should pass with contact email + real home dir");
}
#[test]
#[serial_test::serial]
fn unknown_action_errors() {
let _g = unset_all_doiget_config_env();
let err = run("bogus".into()).expect_err("bogus action should error");
let msg = format!("{err}");
assert!(
msg.contains("unknown config action"),
"unexpected error message: {msg}"
);
}
}