#![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, authors: Vec<String>) -> (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,
year: Some(2025),
doi: Some(Doi::parse(&doi).expect("valid DOI")),
arxiv_id: None,
abstract_: None,
venue: Some("Phys. Rev. X".to_string()),
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(2025, 5, 6, 12, 0, 0)
.single()
.expect("valid timestamp"),
source: "unpaywall".to_string(),
license: "CC-BY-4.0".to_string(),
size_bytes: 1234,
mcp_call_id: 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("quantum", "Quantum Stuff", vec!["Bob Generic".to_string()]);
let (k2, m2) = fixture(
"researcher",
"An Unrelated Title",
vec!["Alice Researcher".to_string()],
);
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 search_finds_by_title_substring() {
let (_dir_guard, root) = seeded_store();
doiget(&root)
.args(["search", "quantum"])
.assert()
.success()
.stdout(predicate::str::contains("Quantum Stuff"))
.stdout(predicate::str::contains("doi_10.1234_quantum"));
}
#[test]
fn search_finds_by_authors_substring() {
let (_dir_guard, root) = seeded_store();
doiget(&root)
.args(["search", "Researcher"])
.assert()
.success()
.stdout(predicate::str::contains("An Unrelated Title"))
.stdout(predicate::str::contains("doi_10.1234_researcher"));
}
#[test]
fn search_returns_no_match_for_unknown_query() {
let (_dir_guard, root) = seeded_store();
let assert = doiget(&root)
.args(["search", "ZZZ-no-such-token"])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone())
.expect("doiget search stdout was not UTF-8");
assert_eq!(
stdout, "safekey\tyear\ttitle\tfetched_at\n",
"no-match search should produce header-only stdout, got:\n{stdout}"
);
}
#[test]
fn search_rejects_empty_query() {
let (_dir_guard, root) = seeded_store();
doiget(&root)
.args(["search", ""])
.assert()
.failure()
.stderr(predicate::str::contains("search query is empty"));
}
#[test]
fn search_json_emits_array_of_entries() {
let (_dir_guard, root) = seeded_store();
let out = doiget(&root)
.env("DOIGET_MODE", "json")
.args(["search", "quantum"])
.assert()
.success()
.get_output()
.stdout
.clone();
let s = String::from_utf8(out).expect("search JSON stdout utf-8");
let v: serde_json::Value = serde_json::from_str(&s).expect("search JSON parses");
let arr = v.as_array().expect("search JSON is an array");
assert!(!arr.is_empty(), "seeded query should match >=1 entry");
for entry in arr {
assert!(entry["safekey"].is_string(), "safekey is a string");
assert!(entry["title"].is_string(), "title is a string");
}
}