#![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, Safekey, 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(doi_suffix: &str, title: &str, year: i32, fetched_year: i32) -> (Safekey, Metadata) {
let doi = format!("10.1234/{doi_suffix}");
let ref_ = doiget_core::Ref::Doi(Doi::parse(&doi).expect("valid DOI"));
let safekey = ref_.safekey();
let m = Metadata {
schema_version: SCHEMA_VERSION.to_string(),
title: title.to_string(),
authors: vec!["Alice Researcher".to_string()],
year: Some(year),
doi: Some(Doi::parse(&doi).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: None,
issn: None,
isbn: None,
type_: Some("journal-article".to_string()),
keywords: vec![],
url: None,
pdf_path: None,
doiget: Some(DoigetExtension {
fetched_at: chrono::Utc
.with_ymd_and_hms(fetched_year, 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,
tags: Vec::new(),
collections: Vec::new(),
annotation: None,
}),
other: BTreeMap::new(),
};
(safekey, m)
}
fn seeded_store() -> (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 (k1, m1) = fixture("alpha", "First Quantum Result", 2024, 2024);
let (k2, m2) = fixture("beta", "Second Quantum Result", 2026, 2026);
store.write(&k1, &m1, None).expect("seed entry 1");
store.write(&k2, &m2, None).expect("seed entry 2");
(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())
.env("DOIGET_MODE", "human");
cmd
}
#[test]
fn info_prints_metadata_for_stored_entry() {
let (_dir_guard, root) = seeded_store();
doiget(&root)
.args(["info", "10.1234/alpha"])
.assert()
.success()
.stdout(predicate::str::contains("First Quantum Result"));
}
#[test]
fn info_fails_for_missing_entry() {
let (_dir_guard, root) = seeded_store();
doiget(&root)
.args(["info", "10.9999/missing"])
.assert()
.failure()
.stderr(predicate::str::contains("no entry for"));
}
#[test]
fn list_recent_prints_seeded_entries_in_recency_order() {
let (_dir_guard, root) = seeded_store();
let assert = doiget(&root).args(["list-recent"]).assert().success();
let stdout = String::from_utf8(assert.get_output().stdout.clone())
.expect("doiget list-recent stdout was not UTF-8");
assert!(
stdout.starts_with("safekey\tyear\ttitle\tfetched_at"),
"expected header line, got:\n{stdout}"
);
assert!(
stdout.contains("First Quantum Result"),
"missing seeded title 'First Quantum Result' in stdout:\n{stdout}"
);
assert!(
stdout.contains("Second Quantum Result"),
"missing seeded title 'Second Quantum Result' in stdout:\n{stdout}"
);
let pos_2026 = stdout
.find("Second Quantum Result")
.expect("2026 entry present");
let pos_2024 = stdout
.find("First Quantum Result")
.expect("2024 entry present");
assert!(
pos_2026 < pos_2024,
"expected 2026 entry before 2024 entry; stdout:\n{stdout}"
);
assert!(
stdout.contains("doi_10.1234_alpha"),
"safekey 'doi_10.1234_alpha' missing from stdout:\n{stdout}"
);
assert!(
stdout.contains("doi_10.1234_beta"),
"safekey 'doi_10.1234_beta' missing from stdout:\n{stdout}"
);
}
#[test]
fn list_recent_with_explicit_limit_truncates() {
let (_dir_guard, root) = seeded_store();
let assert = doiget(&root).args(["list-recent", "1"]).assert().success();
let stdout = String::from_utf8(assert.get_output().stdout.clone())
.expect("doiget list-recent stdout was not UTF-8");
let line_count = stdout.lines().count();
assert_eq!(
line_count, 2,
"with --limit=1 expected header + 1 row, got {line_count} lines:\n{stdout}"
);
assert!(
stdout.contains("Second Quantum Result"),
"expected most-recent (2026) entry in 1-row output:\n{stdout}"
);
assert!(
!stdout.contains("First Quantum Result"),
"did not expect older (2024) entry with --limit=1; stdout:\n{stdout}"
);
}
#[test]
fn list_recent_on_empty_store_prints_only_header() {
let dir = TempDir::new().expect("tempdir");
let root = utf8_path(&dir).join("papers");
FsStore::new(root.clone()).expect("FsStore::new");
let assert = doiget(&root).args(["list-recent"]).assert().success();
let stdout = String::from_utf8(assert.get_output().stdout.clone())
.expect("doiget list-recent stdout was not UTF-8");
assert_eq!(
stdout, "safekey\tyear\ttitle\tfetched_at\tpdf\n",
"empty store should produce header-only stdout, got:\n{stdout}"
);
}
fn store_with_one_stub() -> (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 (k1, m1) = fixture("gotpdf", "A Paper We Actually Have", 2024, 2024);
store.write(&k1, &m1, None).expect("seed fetched entry");
let (k2, mut m2) = fixture("nopdf", "A Paper We Only Know About", 2023, 2023);
if let Some(d) = m2.doiget.as_mut() {
d.size_bytes = 0;
}
store
.write(&k2, &m2, None)
.expect("seed metadata-only entry");
(dir, root)
}
#[test]
fn list_recent_distinguishes_a_metadata_only_entry_from_a_fetched_one() {
let (_guard, root) = store_with_one_stub();
let assert = doiget(&root).args(["list-recent"]).assert().success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf-8");
let have = stdout
.lines()
.find(|l| l.contains("A Paper We Actually Have"))
.unwrap_or_else(|| panic!("missing fetched row in:\n{stdout}"));
let stub = stdout
.lines()
.find(|l| l.contains("A Paper We Only Know About"))
.unwrap_or_else(|| panic!("missing metadata-only row in:\n{stdout}"));
assert!(
have.ends_with("1.2 kB"),
"a stored PDF must show its size; got:\n{have}"
);
assert!(
stub.ends_with('-'),
"a metadata-only entry must be visibly different; got:\n{stub}"
);
}
#[test]
fn list_recent_missing_pdf_lists_only_the_stubs() {
let (_guard, root) = store_with_one_stub();
let assert = doiget(&root)
.args(["list-recent", "--missing-pdf"])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf-8");
assert!(
stdout.contains("A Paper We Only Know About"),
"the stub must be listed; got:\n{stdout}"
);
assert!(
!stdout.contains("A Paper We Actually Have"),
"a fetched paper is not missing its PDF; got:\n{stdout}"
);
}
#[test]
fn list_recent_json_carries_size_bytes_and_has_pdf() {
let (_guard, root) = store_with_one_stub();
let assert = doiget(&root)
.args(["--mode", "json", "list-recent"])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf-8");
let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let entries = v["entries"].as_array().expect("entries array");
let stub = entries
.iter()
.find(|e| e["title"].as_str() == Some("A Paper We Only Know About"))
.unwrap_or_else(|| panic!("stub missing from {stdout}"));
assert_eq!(stub["size_bytes"], serde_json::json!(0));
assert_eq!(stub["has_pdf"], serde_json::json!(false));
let have = entries
.iter()
.find(|e| e["title"].as_str() == Some("A Paper We Actually Have"))
.unwrap_or_else(|| panic!("fetched entry missing from {stdout}"));
assert_eq!(have["has_pdf"], serde_json::json!(true));
}
#[test]
fn info_json_emits_metadata_object() {
let (_dir_guard, root) = seeded_store();
let out = doiget(&root)
.env("DOIGET_MODE", "json")
.args(["info", "10.1234/alpha"])
.assert()
.success()
.get_output()
.stdout
.clone();
let s = String::from_utf8(out).expect("info JSON stdout utf-8");
let v: serde_json::Value = serde_json::from_str(&s).expect("info JSON parses");
assert_eq!(v["ok"], true, "envelope ok");
assert!(v["safekey"].is_string(), "safekey present");
assert_eq!(v["metadata"]["title"], "First Quantum Result");
}
#[test]
fn list_recent_json_emits_array_of_entries() {
let (_dir_guard, root) = seeded_store();
let out = doiget(&root)
.env("DOIGET_MODE", "json")
.args(["list-recent"])
.assert()
.success()
.get_output()
.stdout
.clone();
let s = String::from_utf8(out).expect("list-recent JSON stdout utf-8");
let v: serde_json::Value = serde_json::from_str(&s).expect("list-recent JSON parses");
assert_eq!(v["ok"], true, "envelope ok");
let arr = v["entries"].as_array().expect("entries is an array");
assert_eq!(arr.len(), 2, "seeded store has 2 entries");
assert_eq!(v["count"], 2, "count matches entries length");
for entry in arr {
assert!(entry["safekey"].is_string(), "safekey is a string");
assert!(entry["title"].is_string(), "title is a string");
}
}
fn doiget_implicit(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())
.env_remove("DOIGET_MODE");
cmd
}
#[test]
fn info_emits_under_implicit_non_tty_quiet() {
let (_dir_guard, root) = seeded_store();
doiget_implicit(&root)
.args(["info", "10.1234/alpha"])
.assert()
.success()
.stdout(predicate::str::contains("First Quantum Result"));
}
#[test]
fn list_recent_emits_under_implicit_non_tty_quiet() {
let (_dir_guard, root) = seeded_store();
doiget_implicit(&root)
.args(["list-recent"])
.assert()
.success()
.stdout(predicate::str::contains("First Quantum Result"));
}
#[test]
fn info_explicit_quiet_flag_suppresses_stdout() {
let (_dir_guard, root) = seeded_store();
doiget_implicit(&root)
.args(["--quiet", "info", "10.1234/alpha"])
.assert()
.success()
.stdout(predicate::str::is_empty());
}
#[test]
fn list_recent_explicit_quiet_flag_suppresses_stdout() {
let (_dir_guard, root) = seeded_store();
doiget_implicit(&root)
.args(["-q", "list-recent"])
.assert()
.success()
.stdout(predicate::str::is_empty());
}
#[test]
fn info_explicit_quiet_env_suppresses_stdout() {
let (_dir_guard, root) = seeded_store();
doiget_implicit(&root)
.env("DOIGET_MODE", "quiet")
.args(["info", "10.1234/alpha"])
.assert()
.success()
.stdout(predicate::str::is_empty());
}