use anyhow::{Context, Result};
use camino::Utf8PathBuf;
use super::fetch::CliExit;
#[derive(Debug, serde::Serialize)]
pub struct ResolvedConfig {
pub store_root: Utf8PathBuf,
pub store_root_source: String,
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>,
pub contact_email_source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub unpaywall_email: Option<String>,
pub unpaywall_email_source: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EmailSource {
Env(&'static str),
ConfigFile(&'static str),
InheritedFromContact,
Unset,
}
impl EmailSource {
fn label(self) -> String {
match self {
Self::Env(v) => v.to_string(),
Self::ConfigFile(k) => format!("[network] {k} in config.toml"),
Self::InheritedFromContact => "inherited from contact_email".to_string(),
Self::Unset => "unset".to_string(),
}
}
}
fn resolve_email(
env_var: &'static str,
key: &'static str,
from_file: Option<String>,
) -> (Option<String>, EmailSource) {
if let Some(v) = std::env::var(env_var).ok().filter(|s| !s.trim().is_empty()) {
return (Some(v), EmailSource::Env(env_var));
}
match from_file {
Some(v) => (Some(v), EmailSource::ConfigFile(key)),
None => (None, EmailSource::Unset),
}
}
impl ResolvedConfig {
pub fn from_env() -> Result<Self> {
let cfg = super::fetch::config_dir_utf8()?;
let (store_root, store_root_source) = super::resolve_store_root_with_source()?;
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");
let file = doiget_core::user_extension::load(&config_path).unwrap_or_default();
let (contact_email, contact_email_source) =
resolve_email("DOIGET_CONTACT_EMAIL", "contact_email", file.contact_email);
let (unpaywall_email, unpaywall_email_source) = match resolve_email(
"DOIGET_UNPAYWALL_EMAIL",
"unpaywall_email",
file.unpaywall_email,
) {
(None, _) => (
contact_email.clone(),
if contact_email.is_some() {
EmailSource::InheritedFromContact
} else {
EmailSource::Unset
},
),
found => found,
};
Ok(Self {
store_root,
store_root_source: store_root_source.label().to_string(),
log_dir,
log_path,
config_dir,
config_path,
contact_email,
contact_email_source: contact_email_source.label(),
unpaywall_email,
unpaywall_email_source: unpaywall_email_source.label(),
})
}
}
#[allow(clippy::print_stdout, clippy::print_stderr)]
pub async fn run(
action: String,
mode: super::output::OutputMode,
network: bool,
force: bool,
quiet_was_explicit: bool,
) -> Result<()> {
let artifact_quiet = mode == super::output::OutputMode::Quiet && quiet_was_explicit;
let cfg = ResolvedConfig::from_env()?;
if network && action != "doctor" {
eprintln_err("error: --network applies to `config doctor` only");
return Err(anyhow::Error::new(CliExit(2)));
}
if force && action != "init" {
eprintln_err("error: --force applies to `config init` only");
return Err(anyhow::Error::new(CliExit(2)));
}
match action.as_str() {
"show" if artifact_quiet => {}
"show" => match mode {
super::output::OutputMode::Quiet => {
let s = toml::to_string_pretty(&cfg)?;
print!("{s}");
}
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}");
}
},
"init" => init_config(&cfg, force, mode)?,
"path" if artifact_quiet => {}
"path" => match mode {
super::output::OutputMode::Quiet => {
println!("{}", cfg.config_path);
}
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;
let store_parent = cfg.store_root.parent().map(|p| p.as_str()).unwrap_or("");
check(
&format!("store_root: {}", cfg.store_root),
true,
None,
&mut all_ok,
);
eprintln!(" from: {}", cfg.store_root_source);
if cfg.store_root_source == super::StoreRootSource::CwdDefault.label() {
eprintln!(
" note: relative to the current directory (ADR-0036). Set DOIGET_STORE_ROOT"
);
eprintln!(" (or store.root in config.toml) for one central library.");
}
check(
"store_root parent exists",
cfg.store_root.parent().map(|p| p.exists()).unwrap_or(true),
Some(&format!(
"create the parent directory or override via \
DOIGET_STORE_ROOT\n \
missing parent: {store_parent}"
)),
&mut all_ok,
);
let log_parent = cfg.log_dir.parent().map(|p| p.as_str()).unwrap_or("");
check(
"log_dir parent exists",
cfg.log_dir.parent().map(|p| p.exists()).unwrap_or(true),
Some(&format!(
"create the parent directory or override via \
DOIGET_LOG_PATH\n \
missing parent: {log_parent}"
)),
&mut all_ok,
);
check(
&format!("contact_email set (from: {})", cfg.contact_email_source),
cfg.contact_email.is_some(),
Some(
"set DOIGET_CONTACT_EMAIL, or [network] contact_email in config.toml\n \
e.g. export DOIGET_CONTACT_EMAIL=you@institution.edu\n \
(polite User-Agent header + Unpaywall API; without it requests go\n \
out as doiget@localhost from the non-polite pool, where a throttled\n \
answer is indistinguishable from `no OA copy` — #504)",
),
&mut all_ok,
);
check(
&format!(
"unpaywall_email: {} (from: {})",
cfg.unpaywall_email.as_deref().unwrap_or("unset"),
cfg.unpaywall_email_source
),
true,
None,
&mut all_ok,
);
match doiget_core::user_extension::load(&cfg.config_path) {
Ok(cfg_ext) => {
check(
&format!(
"user-extension hosts loaded: {} (academic={}, oa_registries={})",
cfg_ext.additional_hosts.len(),
cfg_ext.trust_academic_repos,
cfg_ext.trust_oa_registries
),
true,
None,
&mut all_ok,
);
let widened = cfg_ext.trust_academic_repos
|| cfg_ext.trust_oa_registries
|| !cfg_ext.additional_hosts.is_empty();
if !widened {
eprintln!(" note: built-in allowlist only. To widen it, edit");
eprintln!(" {}", cfg.config_path);
eprintln!(
" [network] trust_academic_repos = true # *.ac.uk, \
*.ac.jp, ..."
);
eprintln!(
" [network] trust_oa_registries = true # DOAJ, \
SciELO, Zenodo, ..."
);
eprintln!(
" [[network.additional_hosts]] # anything \
else — docs/CONFIG.md §3.1"
);
}
}
Err(e) => check(
&format!("user-extension config invalid: {e}"),
false,
Some(&format!(
"fix {} — see docs/CONFIG.md §3 for the \
[[network.additional_hosts]] schema",
cfg.config_path
)),
&mut all_ok,
),
}
let cred_path = cfg.config_dir.join("credentials.toml");
match doiget_core::credentials::load(&cred_path) {
Ok(creds) => {
check(
&format!(
"credentials.toml keys loaded: {} ({})",
creds.len(),
if creds.is_empty() {
"none — TDM sources need DOIGET_KEY_* or this file"
} else {
"publisher names only; keys are never printed"
}
),
true,
None,
&mut all_ok,
);
for advisory in creds.advisories() {
check(
&format!("credentials.toml: {advisory}"),
false,
None,
&mut all_ok,
);
}
}
Err(e) => check(
&format!("credentials.toml invalid: {e}"),
false,
Some(&format!(
"fix {cred_path} — see docs/CONFIG.md §6 for the \
[tdm.<publisher>] schema. Only `api_key` is read; \
the agreement is DOIGET_AGREE_TDM_<PUBLISHER>=1 in \
the environment (ADR-0050)"
)),
&mut all_ok,
),
}
if network {
network_report(&cfg).await;
}
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 `init` / `show` / `path` / `doctor`"
));
return Err(anyhow::Error::new(CliExit(2)));
}
}
Ok(())
}
#[allow(clippy::print_stderr)]
fn eprintln_err(msg: &str) {
eprintln!("{msg}");
}
pub(crate) fn config_template() -> &'static str {
r#"# ~/.config/doiget/config.toml — written by `doiget config init`.
#
# Every field is optional and every line below is commented out: this file
# documents the choices, it does not change behaviour until you uncomment
# something. Re-run `doiget config init --force` to restore this template.
#
# See docs/CONFIG.md for the full schema, and run `doiget config doctor`
# (add --network for outbound checks) to see what is actually in effect.
[store]
# Where fetched papers are written.
#
# DEFAULT: `./papers` — relative to the CURRENT WORKING DIRECTORY, so it
# moves with you (ADR-0036). That is deliberate: artifacts land where the
# work is, instead of somewhere you have to go looking for. The cost is that
# fetching from many directories leaves several small stores. Set this for a
# single central library.
#
# Overridden by DOIGET_STORE_ROOT and by --store-root, which share a rung
# above this one. A leading `~` IS expanded here (a config file has no
# shell, unlike the env var).
# root = "/home/you/papers"
[network]
# Contact address for the polite pool. STRONGLY RECOMMENDED.
#
# Without it doiget still queries Unpaywall and Crossref, but as
# `doiget@localhost`, from the non-polite pool — where you may be throttled
# or refused. Since the automatic arXiv-preprint fallback fires on what
# Unpaywall reports, a throttled response quietly costs you that fallback
# too, and the run still exits 0 saying `no OA PDF available`.
#
# This is also what `doiget config doctor` checks. Overridden by
# DOIGET_CONTACT_EMAIL, one rung above.
# contact_email = "you@institution.edu"
# Only if Unpaywall should see a DIFFERENT address from contact_email above
# — most people should leave this alone. Overridden by
# DOIGET_UNPAYWALL_EMAIL.
# unpaywall_email = "you@institution.edu"
# Allow the curated academic-repository suffixes, i.e. where institutions
# host their own Green OA:
# *.ac.uk *.ac.jp *.jst.go.jp *.edu.au *.edu.cn *.ac.cn *.edu.pl
# *.ac.nz *.ac.za *.ac.in *.edu.br *.edu.tw *.edu.tr *.edu.ar
# *.edu.mx
#
# Without this, an OA PDF on e.g. `strathprints.strath.ac.uk` is denied with
# `error[CAPABILITY_DENIED] ... redirect_not_in_allowlist`.
# trust_academic_repos = false
# Allow the curated cross-publisher OA registries and repositories:
# scielo.org zenodo.org osf.io hal.science core.ac.uk (+ subdomains)
#
# Separate from the flag above because the trust argument differs: one is
# "this institution publishes its own work here", the other is "this registry
# indexes open content across publishers". DOAJ needs no flag — it is on the
# default allowlist (ADR-0037).
# trust_oa_registries = false
# Anything outside both curated sets. Each entry is a literal FQDN or a
# single-suffix wildcard (`*.example.edu`); multi-segment globs are rejected
# at load time, as are unknown keys in this table.
# [[network.additional_hosts]]
# host = "repository.example.edu"
# note = "free-text, optional"
# Request timeouts, in seconds.
# connect_timeout_sec = 10
# read_timeout_sec = 60
# total_timeout_sec = 300
[output]
# mode = "human" # human | json | quiet | mcp
# color = "auto" # auto | always | never
# progress = false
# emoji = false
"#
}
#[allow(clippy::print_stdout, clippy::print_stderr)]
fn init_config(cfg: &ResolvedConfig, force: bool, mode: super::output::OutputMode) -> Result<()> {
let path = &cfg.config_path;
let existed = path.exists();
if existed && !force {
eprintln_err(&format!(
"error: {path} already exists; pass --force to overwrite it"
));
return Err(anyhow::Error::new(CliExit(2)));
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent.as_std_path())
.with_context(|| format!("creating config directory {parent}"))?;
}
std::fs::write(path.as_std_path(), config_template())
.with_context(|| format!("writing {path}"))?;
match mode {
super::output::OutputMode::Quiet => {}
super::output::OutputMode::Json => {
println!(
"{}",
serde_json::json!({
"ok": true,
"config_path": path.as_str(),
"overwritten": existed,
})
);
}
_ => {
let verb = if existed { "overwrote" } else { "wrote" };
println!("{verb} {path}");
eprintln_err(
" = note: every field is commented out; nothing changed until you edit it",
);
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProbeVerdict {
Ok {
status: u16,
bytes: usize,
},
BotChallenge {
status: u16,
},
Refused {
status: u16,
},
Status {
status: u16,
},
NotAllowlisted,
Unreachable {
reason: String,
},
}
impl ProbeVerdict {
pub fn classify(status: u16, body_bytes: usize) -> Self {
match status {
200..=299 if body_bytes == 0 => Self::BotChallenge { status },
200..=299 => Self::Ok {
status,
bytes: body_bytes,
},
401 | 403 => Self::Refused { status },
other => Self::Status { status: other },
}
}
pub fn render(&self) -> String {
match self {
Self::Ok { status, bytes } => format!("{status} {bytes} bytes ok"),
Self::BotChallenge { status } => {
format!("{status} empty body bot challenge — needs a TDM key or a real browser")
}
Self::Refused { status } => {
format!("{status} reached, refused — subscription or credential")
}
Self::Status { status } => format!("{status} unexpected status"),
Self::NotAllowlisted => {
"not allowlisted no request sent; add the host or enable a trust flag".to_string()
}
Self::Unreachable { reason } => format!("unreachable {reason}"),
}
}
}
fn contact_report_lines(contact_email: Option<&str>) -> Vec<String> {
match contact_email {
Some(e) => vec![format!(
" contact polite User-Agent as {e} (all outbound requests)"
)],
None => vec![
" contact unset — set DOIGET_CONTACT_EMAIL or [network] contact_email"
.to_string(),
" in config.toml. Until then every outbound request, metadata"
.to_string(),
" AND publisher content, goes out on the non-polite pool and"
.to_string(),
" may be throttled (HTTP 429) or refused".to_string(),
],
}
}
#[allow(clippy::print_stderr)]
async fn network_report(cfg: &ResolvedConfig) {
eprintln!();
eprintln!("network (--network):");
for var in ["HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy"] {
if let Ok(v) = std::env::var(var) {
if !v.is_empty() {
eprintln!(" proxy {var}={v}");
}
}
}
eprintln!(
" egress not probed (needs a third-party echo service; try `curl ifconfig.me`)"
);
eprintln!(" a proxy fixes addressing, never a bot wall");
for line in contact_report_lines(cfg.contact_email.as_deref()) {
eprintln!("{line}");
}
let client = match crate::commands::fetch::build_http_client(None) {
Ok(c) => c,
Err(e) => {
eprintln!(" probes unavailable: {e}");
return;
}
};
let Some(allow) = client.source_allowlist("oa-publisher") else {
eprintln!(" probes unavailable: oa-publisher source not registered");
return;
};
eprintln!(
" oa-publisher {} host patterns allowlisted",
allow.redirect_hosts.len()
);
const PROBES: &[(&str, &str)] = &[
("link.springer.com", "https://link.springer.com/robots.txt"),
("www.mdpi.com", "https://www.mdpi.com/robots.txt"),
("journals.plos.org", "https://journals.plos.org/robots.txt"),
("arxiv.org", "https://arxiv.org/robots.txt"),
(
"ieeexplore.ieee.org",
"https://ieeexplore.ieee.org/robots.txt",
),
("dl.acm.org", "https://dl.acm.org/robots.txt"),
("epubs.siam.org", "https://epubs.siam.org/robots.txt"),
("doaj.org", "https://doaj.org/robots.txt"),
];
for (host, url) in PROBES {
let verdict = if !allow.matches(host) {
ProbeVerdict::NotAllowlisted
} else {
match url::Url::parse(url) {
Err(e) => ProbeVerdict::Unreachable {
reason: format!("bad probe URL: {e}"),
},
Ok(u) => match client.probe("oa-publisher", u).await {
Ok(o) => ProbeVerdict::classify(o.status, o.body_bytes),
Err(e) => ProbeVerdict::Unreachable {
reason: e.to_string(),
},
},
}
};
eprintln!(" probe {host:<22} {}", verdict.render());
}
eprintln!();
eprintln!(" IP-based subscription does not imply fetchability: a publisher WAF can");
eprintln!(" answer a scripted client with a challenge regardless of entitlement. The");
eprintln!(" routes that work are per-publisher TDM credentials (docs/CONFIG.md §6)");
eprintln!(" or a real browser on the subscribing network.");
}
#[allow(clippy::print_stderr)]
fn check(label: &str, ok: bool, tip: Option<&str>, all_ok: &mut bool) {
let mark = if ok { "[ ok ]" } else { "[FAIL]" };
eprintln!("{mark} {label}");
if !ok {
if let Some(t) = tip {
eprintln!(" tip: {t}");
}
*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()
}
fn scoped_config_home(dir: &str) -> Vec<EnvGuard> {
["XDG_CONFIG_HOME", "APPDATA", "HOME", "USERPROFILE"]
.iter()
.map(|v| EnvGuard::set(v, dir))
.collect()
}
#[test]
#[serial_test::serial]
fn from_env_uses_cwd_default_when_unset() {
let _g = unset_all_doiget_config_env();
let cfg = ResolvedConfig::from_env().expect("config resolves on test host");
let cwd =
camino::Utf8PathBuf::from_path_buf(std::env::current_dir().expect("cwd is available"))
.expect("cwd is valid UTF-8");
assert_eq!(
cfg.store_root,
cwd.join("papers"),
"store_root should default to <cwd>/papers when DOIGET_STORE_ROOT is unset; got {}",
cfg.store_root
);
assert_eq!(cfg.contact_email, None);
assert_eq!(cfg.unpaywall_email, None);
assert_eq!(cfg.contact_email_source, "unset");
assert_eq!(cfg.unpaywall_email_source, "unset");
}
#[test]
#[serial_test::serial]
fn contact_email_comes_from_config_toml_when_the_env_is_unset() {
let _g = unset_all_doiget_config_env();
let td = tempfile::TempDir::new().expect("tempdir");
let dir = camino::Utf8PathBuf::from_path_buf(td.path().to_path_buf())
.expect("temp path is UTF-8");
std::fs::create_dir_all(dir.join("doiget").as_std_path()).expect("mkdir");
std::fs::write(
dir.join("doiget").join("config.toml").as_std_path(),
"[network]\ncontact_email = \"file@institution.edu\"\nunpaywall_email = \"up@institution.edu\"\n",
)
.expect("write config");
let _scoped = scoped_config_home(dir.as_str());
let cfg = ResolvedConfig::from_env().expect("config resolves");
assert_eq!(cfg.contact_email.as_deref(), Some("file@institution.edu"));
assert_eq!(cfg.unpaywall_email.as_deref(), Some("up@institution.edu"));
assert_eq!(
cfg.contact_email_source, "[network] contact_email in config.toml",
"doctor must name the rung that answered, or an inert setting looks like a live one"
);
}
#[test]
#[serial_test::serial]
fn an_unset_unpaywall_address_reports_the_contact_it_actually_inherits() {
let _g = unset_all_doiget_config_env();
let td = tempfile::TempDir::new().expect("tempdir");
let dir = camino::Utf8PathBuf::from_path_buf(td.path().to_path_buf())
.expect("temp path is UTF-8");
let _scoped = scoped_config_home(dir.as_str());
let _c = EnvGuard::set("DOIGET_CONTACT_EMAIL", "only@institution.edu");
let cfg = ResolvedConfig::from_env().expect("config resolves");
assert_eq!(
cfg.unpaywall_email.as_deref(),
Some("only@institution.edu"),
"the report must match what the fetch path sends"
);
assert_eq!(cfg.unpaywall_email_source, "inherited from contact_email");
}
#[test]
#[serial_test::serial]
fn with_no_address_anywhere_unpaywall_still_reports_unset() {
let _g = unset_all_doiget_config_env();
let td = tempfile::TempDir::new().expect("tempdir");
let dir = camino::Utf8PathBuf::from_path_buf(td.path().to_path_buf())
.expect("temp path is UTF-8");
let _scoped = scoped_config_home(dir.as_str());
let cfg = ResolvedConfig::from_env().expect("config resolves");
assert_eq!(cfg.unpaywall_email, None);
assert_eq!(cfg.unpaywall_email_source, "unset");
}
#[test]
#[serial_test::serial]
fn the_env_var_outranks_the_config_file_for_each_address() {
let _g = unset_all_doiget_config_env();
let td = tempfile::TempDir::new().expect("tempdir");
let dir = camino::Utf8PathBuf::from_path_buf(td.path().to_path_buf())
.expect("temp path is UTF-8");
std::fs::create_dir_all(dir.join("doiget").as_std_path()).expect("mkdir");
std::fs::write(
dir.join("doiget").join("config.toml").as_std_path(),
"[network]\ncontact_email = \"file@institution.edu\"\n",
)
.expect("write config");
let _scoped = scoped_config_home(dir.as_str());
std::env::set_var("DOIGET_CONTACT_EMAIL", "env@institution.edu");
let cfg = ResolvedConfig::from_env().expect("config resolves");
std::env::remove_var("DOIGET_CONTACT_EMAIL");
assert_eq!(cfg.contact_email.as_deref(), Some("env@institution.edu"));
assert_eq!(cfg.contact_email_source, "DOIGET_CONTACT_EMAIL");
}
#[test]
#[serial_test::serial]
fn config_path_matches_the_resolver_the_reader_uses() {
struct EnvGuard(&'static str, Option<String>);
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.1 {
Some(v) => std::env::set_var(self.0, v),
None => std::env::remove_var(self.0),
}
}
}
let td = tempfile::TempDir::new().expect("tempdir");
let _guards: Vec<EnvGuard> = ["XDG_CONFIG_HOME", "APPDATA", "HOME", "USERPROFILE"]
.iter()
.map(|k| EnvGuard(k, std::env::var(k).ok()))
.collect();
std::env::set_var("XDG_CONFIG_HOME", td.path());
let cfg = ResolvedConfig::from_env().expect("resolve config");
let reader = crate::commands::fetch::config_dir_utf8()
.expect("reader resolves")
.join("doiget")
.join("config.toml");
assert_eq!(
cfg.config_path, reader,
"doctor must validate the file the reader loads"
);
assert!(
cfg.config_path.as_str().starts_with(
camino::Utf8Path::from_path(td.path())
.expect("utf-8 tempdir")
.as_str()
),
"XDG_CONFIG_HOME must win on every platform; got {}",
cfg.config_path
);
}
#[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("config resolves 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("config resolves 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]
fn template_documents_every_silently_defaulting_key() {
let t = config_template();
for key in [
"[store]",
"root =",
"contact_email",
"unpaywall_email",
"trust_academic_repos",
"trust_oa_registries",
"[[network.additional_hosts]]",
] {
assert!(t.contains(key), "template must mention {key}");
}
assert!(
t.contains("CURRENT WORKING DIRECTORY"),
"store root default"
);
assert!(
t.contains("doiget@localhost"),
"non-polite pool consequence"
);
assert!(t.contains("DOAJ needs no flag"), "post-ADR-0037 accuracy");
}
#[test]
fn template_is_entirely_commented_out() {
for line in config_template().lines() {
let t = line.trim();
if t.is_empty() || t.starts_with('#') {
continue;
}
assert!(
t.starts_with('[') && t.ends_with(']') && !t.starts_with("[["),
"only bare section headers may be live; found: {line:?}"
);
}
}
#[test]
fn template_parses_as_toml() {
let v: toml::Value = toml::from_str(config_template()).expect("template is valid TOML");
for (name, tbl) in v.as_table().expect("table") {
assert!(
tbl.as_table().expect("section").is_empty(),
"section [{name}] must be empty in the template"
);
}
}
#[test]
fn empty_2xx_body_is_a_bot_challenge_not_a_success() {
assert_eq!(
ProbeVerdict::classify(202, 0),
ProbeVerdict::BotChallenge { status: 202 }
);
assert_eq!(
ProbeVerdict::classify(200, 0),
ProbeVerdict::BotChallenge { status: 200 },
"an empty 200 is the same holding response wearing a different code"
);
assert!(
ProbeVerdict::classify(202, 0)
.render()
.contains("bot challenge"),
"the verdict must name the diagnosis, not just the status"
);
}
#[test]
fn non_empty_2xx_is_ok() {
assert_eq!(
ProbeVerdict::classify(200, 1234),
ProbeVerdict::Ok {
status: 200,
bytes: 1234
}
);
}
#[test]
fn auth_statuses_are_refused_not_challenged() {
for code in [401u16, 403] {
assert_eq!(
ProbeVerdict::classify(code, 0),
ProbeVerdict::Refused { status: code },
"{code} must not be misread as a bot challenge"
);
}
assert_eq!(
ProbeVerdict::classify(404, 0),
ProbeVerdict::Status { status: 404 }
);
}
#[test]
fn every_verdict_renders_non_empty_advice() {
let all = [
ProbeVerdict::Ok {
status: 200,
bytes: 1,
},
ProbeVerdict::BotChallenge { status: 202 },
ProbeVerdict::Refused { status: 403 },
ProbeVerdict::Status { status: 500 },
ProbeVerdict::NotAllowlisted,
ProbeVerdict::Unreachable {
reason: "dns".into(),
},
];
for v in &all {
assert!(!v.render().trim().is_empty(), "{v:?} rendered empty");
}
}
#[tokio::test]
#[serial_test::serial]
async fn doctor_fails_without_contact_email() {
let _g = unset_all_doiget_config_env();
let err = run(
"doctor".into(),
crate::commands::output::OutputMode::Human,
false,
false,
false,
)
.await
.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"
);
}
#[tokio::test]
#[serial_test::serial]
async 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,
false,
false,
false,
)
.await
.expect("doctor should pass with contact email + valid config dir and cwd");
}
#[cfg(target_os = "linux")]
#[tokio::test]
#[serial_test::serial]
async fn doctor_fails_with_malformed_user_extension_config() {
let _g = unset_all_doiget_config_env();
let _email = EnvGuard::set("DOIGET_CONTACT_EMAIL", "alice@example.org");
let tmp = tempfile::TempDir::new().expect("tempdir");
let cfg_root = camino::Utf8Path::from_path(tmp.path()).expect("utf8 tempdir");
let doiget_dir = cfg_root.join("doiget");
std::fs::create_dir_all(doiget_dir.as_std_path()).expect("mk dir");
let config_toml = doiget_dir.join("config.toml");
std::fs::write(
config_toml.as_std_path(),
"[[network.additional_hosts]]\nhost = \"\"\n",
)
.expect("write config.toml");
let _x = EnvGuard::set("XDG_CONFIG_HOME", cfg_root.as_str());
let err = run(
"doctor".into(),
crate::commands::output::OutputMode::Human,
false,
false,
false,
)
.await
.expect_err("doctor should fail when user-extension config is malformed");
let cli_exit = err
.downcast_ref::<CliExit>()
.expect("failing doctor must carry a CliExit");
assert_eq!(cli_exit.0, 2);
}
#[cfg(target_os = "linux")]
#[tokio::test]
#[serial_test::serial]
async fn doctor_fails_when_credentials_toml_is_malformed() {
let _g = unset_all_doiget_config_env();
let _email = EnvGuard::set("DOIGET_CONTACT_EMAIL", "alice@example.org");
let tmp = tempfile::TempDir::new().expect("tempdir");
let cfg_root = camino::Utf8Path::from_path(tmp.path()).expect("utf8 tempdir");
let doiget_dir = cfg_root.join("doiget");
std::fs::create_dir_all(doiget_dir.as_std_path()).expect("mk dir");
std::fs::write(
doiget_dir.join("credentials.toml").as_std_path(),
"[tdm.elsevier]\napi_key = \"sk-unterminated\n",
)
.expect("write credentials.toml");
let _x = EnvGuard::set("XDG_CONFIG_HOME", cfg_root.as_str());
let err = run(
"doctor".into(),
crate::commands::output::OutputMode::Human,
false,
false,
false,
)
.await
.expect_err("doctor must fail when credentials.toml is malformed");
assert_eq!(
err.downcast_ref::<CliExit>()
.expect("failing doctor must carry a CliExit")
.0,
2
);
}
#[cfg(target_os = "linux")]
#[tokio::test]
#[serial_test::serial]
async fn doctor_fails_when_credentials_toml_carries_an_advisory() {
let _g = unset_all_doiget_config_env();
let _email = EnvGuard::set("DOIGET_CONTACT_EMAIL", "alice@example.org");
let tmp = tempfile::TempDir::new().expect("tempdir");
let cfg_root = camino::Utf8Path::from_path(tmp.path()).expect("utf8 tempdir");
let doiget_dir = cfg_root.join("doiget");
std::fs::create_dir_all(doiget_dir.as_std_path()).expect("mk dir");
std::fs::write(
doiget_dir.join("credentials.toml").as_std_path(),
"[tdm.aps]\napi_key = \"\"\n",
)
.expect("write credentials.toml");
let _x = EnvGuard::set("XDG_CONFIG_HOME", cfg_root.as_str());
let err = run(
"doctor".into(),
crate::commands::output::OutputMode::Human,
false,
false,
false,
)
.await
.expect_err("a blank api_key must be reported, not passed over");
assert_eq!(
err.downcast_ref::<CliExit>()
.expect("failing doctor must carry a CliExit")
.0,
2
);
}
#[test]
fn check_emits_tip_on_failure_only() {
let mut flag = true;
check("passing check", true, Some("should not appear"), &mut flag);
assert!(flag, "all_ok must stay true for a passing check");
check(
"failing check",
false,
Some("set DOIGET_CONTACT_EMAIL"),
&mut flag,
);
assert!(!flag, "all_ok must flip to false on a failing check");
}
#[tokio::test]
#[serial_test::serial]
async fn unknown_action_errors() {
let _g = unset_all_doiget_config_env();
let err = run(
"bogus".into(),
crate::commands::output::OutputMode::Human,
false,
false,
false,
)
.await
.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"
);
}
fn config_home_with(body: &str) -> (tempfile::TempDir, camino::Utf8PathBuf) {
let td = tempfile::TempDir::new().expect("tempdir");
let root = camino::Utf8PathBuf::try_from(td.path().to_path_buf()).expect("utf-8 tempdir");
std::fs::create_dir_all(root.join("doiget").as_std_path()).expect("mkdir");
std::fs::write(root.join("doiget").join("config.toml").as_std_path(), body)
.expect("write config");
(td, root)
}
#[test]
#[serial_test::serial]
fn store_root_in_config_beats_the_cwd_default() {
let _g = unset_all_doiget_config_env();
let lib_td = tempfile::TempDir::new().expect("tempdir");
let library = camino::Utf8PathBuf::try_from(lib_td.path().to_path_buf())
.expect("utf-8 tempdir")
.as_str()
.replace('\u{5c}', "/");
let (_cfg_td, cfg_root) = config_home_with(&format!("[store]\nroot = \"{library}\"\n"));
let _x = EnvGuard::set("XDG_CONFIG_HOME", cfg_root.as_str());
let cfg = ResolvedConfig::from_env().expect("config resolves");
let cwd_default = camino::Utf8PathBuf::try_from(std::env::current_dir().expect("cwd"))
.expect("utf-8 cwd")
.join("papers");
assert_ne!(
cfg.store_root, cwd_default,
"the config value was ignored and the cwd default answered instead"
);
assert_eq!(
cfg.store_root.as_str().replace('\u{5c}', "/"),
library,
"[store] root must win over the cwd default (ADR-0036 rung 2)"
);
assert_eq!(
cfg.store_root_source,
super::super::StoreRootSource::ConfigFile.label(),
"doctor must attribute it to the config file"
);
}
#[test]
#[serial_test::serial]
fn env_beats_store_root_in_config() {
let _g = unset_all_doiget_config_env();
let (_cfg_td, cfg_root) = config_home_with("[store]\nroot = \"/from/config\"\n");
let _x = EnvGuard::set("XDG_CONFIG_HOME", cfg_root.as_str());
let _e = EnvGuard::set("DOIGET_STORE_ROOT", "/from/env");
let cfg = ResolvedConfig::from_env().expect("config resolves");
assert_eq!(cfg.store_root.as_str(), "/from/env");
assert_eq!(
cfg.store_root_source,
super::super::StoreRootSource::Env.label()
);
}
#[test]
#[serial_test::serial]
fn blank_store_root_in_config_falls_through_to_the_default() {
let _g = unset_all_doiget_config_env();
let (_cfg_td, cfg_root) = config_home_with("[store]\nroot = \" \"\n");
let _x = EnvGuard::set("XDG_CONFIG_HOME", cfg_root.as_str());
let cfg = ResolvedConfig::from_env().expect("config resolves");
assert_eq!(
cfg.store_root_source,
super::super::StoreRootSource::CwdDefault.label(),
"a blank root must not be treated as a configured value"
);
}
#[test]
fn the_contact_advisory_names_every_outbound_request_not_just_unpaywall() {
let joined = contact_report_lines(None).join("\n");
assert!(
joined.contains("DOIGET_CONTACT_EMAIL") && joined.contains("[network] contact_email"),
"both rungs must be named, not only the env var:\n{joined}"
);
assert!(
joined.contains("every outbound request") && joined.contains("publisher content"),
"the advisory must cover the content leg too:\n{joined}"
);
assert!(
!joined.contains("unpaywall"),
"naming only unpaywall is the bug:\n{joined}"
);
assert!(
joined.contains("429"),
"name the symptom the user will actually see:\n{joined}"
);
}
#[test]
fn a_set_contact_address_reports_the_polite_pool_without_a_warning() {
let joined = contact_report_lines(Some("a@example.org")).join("\n");
assert!(joined.contains("a@example.org"), "{joined}");
assert!(joined.contains("all outbound requests"), "{joined}");
assert!(
!joined.contains("429"),
"no warning when it is set:\n{joined}"
);
}
}