#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
use std::collections::BTreeMap;
use assert_cmd::Command;
use camino::Utf8PathBuf;
use chrono::TimeZone;
use predicates::prelude::*;
use tempfile::TempDir;
use doiget_core::store::{DoigetExtension, FsStore, Metadata, Store};
use doiget_core::{Doi, Ref, SCHEMA_VERSION};
fn utf8_path(dir: &TempDir) -> Utf8PathBuf {
Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("temp dir path must be UTF-8")
}
fn fixture(type_: Option<&str>) -> Metadata {
Metadata {
schema_version: SCHEMA_VERSION.to_string(),
title: "Quantum Stuff".to_string(),
authors: vec!["Alice Researcher".to_string(), "Bob Coauthor".to_string()],
year: Some(2026),
doi: Some(Doi::parse("10.1234/example").expect("valid DOI")),
arxiv_id: None,
arxiv_categories: vec![],
abstract_: None,
venue: Some("Phys Rev X".to_string()),
volume: None,
issue: None,
pages: None,
publisher: Some("APS".to_string()),
issn: Some("2160-3308".to_string()),
isbn: None,
type_: type_.map(str::to_string),
keywords: vec![],
url: None,
pdf_path: None,
doiget: Some(DoigetExtension {
fetched_at: chrono::Utc
.with_ymd_and_hms(2026, 5, 6, 12, 0, 0)
.single()
.expect("valid timestamp"),
source: "unpaywall".to_string(),
license: "CC-BY-4.0".to_string(),
oa_status: None,
size_bytes: 1234,
mcp_call_id: None,
}),
other: BTreeMap::new(),
}
}
fn seeded_store(type_: Option<&str>) -> (TempDir, Utf8PathBuf) {
let dir = TempDir::new().expect("tempdir");
let root = utf8_path(&dir).join("papers");
let store = FsStore::new(root.clone()).expect("FsStore::new");
let ref_ = Ref::Doi(Doi::parse("10.1234/example").expect("valid DOI"));
let safekey = ref_.safekey();
let metadata = fixture(type_);
store
.write(&safekey, &metadata, None)
.expect("seed bib entry");
(dir, root)
}
fn doiget(root: &Utf8PathBuf) -> Command {
let mut cmd = Command::cargo_bin("doiget").expect("locate doiget binary");
cmd.env("DOIGET_STORE_ROOT", root.as_str())
.env("HOME", root.as_str())
.env("USERPROFILE", root.as_str());
cmd
}
#[test]
fn bib_emits_article_for_journal_article_type() {
let (_dir_guard, root) = seeded_store(Some("journal-article"));
doiget(&root)
.args(["bib", "doi:10.1234/example"])
.assert()
.success()
.stdout(predicate::str::contains("@article{doi_10.1234_example,"))
.stdout(predicate::str::contains("title = {Quantum Stuff},"))
.stdout(predicate::str::contains(
"author = {Alice Researcher and Bob Coauthor},",
))
.stdout(predicate::str::contains("year = {2026},"))
.stdout(predicate::str::contains("doi = {10.1234/example},"))
.stdout(predicate::str::contains("journal = {Phys Rev X},"))
.stdout(predicate::str::contains("publisher = {APS},"))
.stdout(predicate::str::contains("issn = {2160-3308},"))
.stdout(predicate::str::contains("\n}\n"));
}
#[test]
fn bib_fails_for_missing_entry() {
let dir = TempDir::new().expect("tempdir");
let root = utf8_path(&dir).join("papers");
FsStore::new(root.clone()).expect("FsStore::new");
doiget(&root)
.args(["bib", "doi:10.9999/missing"])
.assert()
.failure()
.stderr(predicate::str::contains("no entry for"));
}
#[test]
fn bib_emits_misc_for_missing_type() {
let (_dir_guard, root) = seeded_store(None);
doiget(&root)
.args(["bib", "doi:10.1234/example"])
.assert()
.success()
.stdout(predicate::str::contains("@misc{doi_10.1234_example,"));
}
fn seed(root: &Utf8PathBuf, doi: &str, title: &str) {
let store = FsStore::new(root.clone()).expect("FsStore::new");
let ref_ = Ref::Doi(Doi::parse(doi).expect("valid DOI"));
let mut m = fixture(Some("journal-article"));
m.doi = Some(Doi::parse(doi).expect("valid DOI"));
m.title = title.to_string();
store.write(&ref_.safekey(), &m, None).expect("seed entry");
}
#[test]
fn bib_all_exports_every_store_entry() {
let dir = TempDir::new().expect("tempdir");
let root = utf8_path(&dir).join("papers");
FsStore::new(root.clone()).expect("FsStore::new");
seed(&root, "10.1234/example", "First Paper");
seed(&root, "10.5678/another", "Second Paper");
doiget(&root)
.args(["bib", "--all"])
.assert()
.success()
.stdout(predicate::str::contains("@article{doi_10.1234_example,"))
.stdout(predicate::str::contains("@article{doi_10.5678_another,"))
.stdout(predicate::str::contains("First Paper"))
.stdout(predicate::str::contains("Second Paper"));
}
#[test]
fn bib_all_on_empty_store_succeeds_with_note() {
let dir = TempDir::new().expect("tempdir");
let root = utf8_path(&dir).join("papers");
FsStore::new(root.clone()).expect("FsStore::new");
doiget(&root)
.args(["bib", "--all"])
.assert()
.success()
.stderr(predicate::str::contains("store is empty"));
}
#[test]
fn bib_from_file_renders_present_and_exits_nonzero_on_missing() {
let dir = TempDir::new().expect("tempdir");
let root = utf8_path(&dir).join("papers");
FsStore::new(root.clone()).expect("FsStore::new");
seed(&root, "10.1234/example", "Present Paper");
let refs = utf8_path(&dir).join("refs.txt");
std::fs::write(
refs.as_std_path(),
"doi:10.1234/example\ndoi:10.9999/missing\n",
)
.expect("write refs file");
doiget(&root)
.args(["bib", "--from-file", refs.as_str()])
.assert()
.failure() .stdout(predicate::str::contains("Present Paper"))
.stderr(predicate::str::contains(
"bib --from-file: exported 1 entries, 1 missing",
));
}
#[test]
fn bib_no_selector_is_a_usage_error() {
let dir = TempDir::new().expect("tempdir");
let root = utf8_path(&dir).join("papers");
FsStore::new(root.clone()).expect("FsStore::new");
doiget(&root)
.args(["bib"])
.assert()
.failure()
.stderr(predicate::str::contains("specify a ref"));
}
#[test]
fn bib_offline_via_cite_renders_from_store() {
let (_dir_guard, root) = seeded_store(Some("journal-article"));
doiget(&root)
.args(["cite", "--offline", "doi:10.1234/example"])
.assert()
.success()
.stdout(predicate::str::contains("@article{doi_10.1234_example,"))
.stdout(predicate::str::contains("title = {Quantum Stuff},"));
}
#[test]
fn cite_offline_missing_entry_fails() {
let dir = TempDir::new().expect("tempdir");
let root = utf8_path(&dir).join("papers");
FsStore::new(root.clone()).expect("FsStore::new");
doiget(&root)
.args(["cite", "--offline", "doi:10.9999/missing"])
.assert()
.failure()
.stderr(predicate::str::contains("no local store entry"));
}
#[test]
fn cite_auto_falls_back_to_store_when_live_resolve_fails() {
let (_dir_guard, root) = seeded_store(Some("journal-article"));
doiget(&root)
.env("DOIGET_CROSSREF_BASE", "http://127.0.0.1:1/")
.env("DOIGET_UNPAYWALL_BASE", "http://127.0.0.1:1/")
.env("DOIGET_CONTACT_EMAIL", "test@example.com")
.args(["cite", "doi:10.1234/example"])
.assert()
.success()
.stdout(predicate::str::contains("@article{doi_10.1234_example,"))
.stderr(predicate::str::contains("note: live resolve failed"));
}