use anyhow::Result;
use camino::Utf8PathBuf;
use super::fetch::CliExit;
#[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_path = match std::env::var("DOIGET_LOG_PATH") {
Ok(s) if !s.is_empty() => Utf8PathBuf::from(s),
_ => cfg.join("doiget").join("access.jsonl"),
};
let log_dir = log_path
.parent()
.map(Utf8PathBuf::from)
.unwrap_or_else(|| cfg.join("doiget"));
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, mode: super::output::OutputMode) -> Result<()> {
let cfg = ResolvedConfig::from_env()?;
match action.as_str() {
"show" => match mode {
super::output::OutputMode::Quiet => {}
super::output::OutputMode::Json => {
let s = serde_json::to_string_pretty(&cfg)
.map_err(|e| anyhow::anyhow!("serialise config to JSON: {e}"))?;
println!("{s}");
}
_ => {
let s = toml::to_string_pretty(&cfg)?;
print!("{s}");
}
},
"path" => match mode {
super::output::OutputMode::Quiet => {}
super::output::OutputMode::Json => {
println!(
"{}",
serde_json::json!({ "config_path": cfg.config_path.as_str() })
);
}
_ => {
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 {
eprintln_err("error: config doctor: one or more checks failed");
return Err(anyhow::Error::new(CliExit(2)));
}
}
other => {
eprintln_err(&format!(
"error: unknown config action: {other}; expected `show` / `path` / `doctor`"
));
return Err(anyhow::Error::new(CliExit(2)));
}
}
Ok(())
}
#[allow(clippy::print_stderr)]
fn eprintln_err(msg: &str) {
eprintln!("{msg}");
}
#[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_PATH",
"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 log_path_follows_doiget_log_path_env() {
let _g = unset_all_doiget_config_env();
let _override = EnvGuard::set("DOIGET_LOG_PATH", "/var/lib/doiget/access.jsonl");
let cfg = ResolvedConfig::from_env().expect("home dir must resolve on test host");
assert_eq!(
cfg.log_path.as_str(),
"/var/lib/doiget/access.jsonl",
"config show must echo DOIGET_LOG_PATH verbatim (issue #142)"
);
assert_eq!(
cfg.log_dir.as_str(),
"/var/lib/doiget",
"log_dir must be derived from log_path's parent so the two cannot drift"
);
}
#[test]
#[serial_test::serial]
fn doctor_fails_without_contact_email() {
let _g = unset_all_doiget_config_env();
let err = run("doctor".into(), crate::commands::output::OutputMode::Human)
.expect_err("doctor should fail when DOIGET_CONTACT_EMAIL is unset");
let cli_exit = err
.downcast_ref::<CliExit>()
.expect("failing doctor must carry a CliExit (issue #149)");
assert_eq!(
cli_exit.0, 2,
"missing/invalid config is misuse → exit 2, not the generic exit 1"
);
}
#[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(), crate::commands::output::OutputMode::Human)
.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(), crate::commands::output::OutputMode::Human)
.expect_err("bogus action should error");
let cli_exit = err
.downcast_ref::<CliExit>()
.expect("unknown config action must carry a CliExit (issue #149)");
assert_eq!(
cli_exit.0, 2,
"unknown config action is misuse → exit 2, not the generic exit 1"
);
}
}