#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
use assert_cmd::Command;
use camino::Utf8PathBuf;
use flate2::write::GzEncoder;
use flate2::Compression;
use predicates::str::contains;
use serial_test::serial;
use std::io::Write as _;
use tempfile::TempDir;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use doiget_cli::commands::output::OutputMode;
use doiget_cli::commands::tex_source::run;
mod common;
use common::env_guard::EnvGuard;
const ENV_KEYS: &[&str] = &[
"DOIGET_ARXIV_BASE",
"DOIGET_ARXIV_SRC_BASE",
"DOIGET_CACHE_ROOT",
"DOIGET_STORE_ROOT",
"DOIGET_LOG_PATH",
"DOIGET_MODE",
"HOME",
"USERPROFILE",
];
const SAMPLE_TEX: &[u8] =
b"\\documentclass{article}\n\\begin{document}\nHello from arXiv.\n\\end{document}";
fn utf8(dir: &TempDir) -> Utf8PathBuf {
Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("temp dir path must be UTF-8")
}
fn single_tex_tar_gz() -> Vec<u8> {
let mut builder = tar::Builder::new(Vec::new());
let mut header = tar::Header::new_gnu();
header.set_size(SAMPLE_TEX.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, "main.tex", std::io::Cursor::new(SAMPLE_TEX))
.expect("tar append");
let tar_bytes = builder.into_inner().expect("tar finish");
let mut enc = GzEncoder::new(Vec::new(), Compression::default());
enc.write_all(&tar_bytes).expect("gz write");
enc.finish().expect("gz finish")
}
fn doiget_subprocess(root: &Utf8PathBuf, server_uri: &str) -> Command {
let mut cmd = Command::cargo_bin("doiget").expect("locate doiget binary");
cmd.env("DOIGET_ARXIV_BASE", server_uri)
.env("DOIGET_ARXIV_SRC_BASE", server_uri)
.env("DOIGET_CACHE_ROOT", root.join("cache").as_str())
.env("DOIGET_STORE_ROOT", root.join("papers").as_str())
.env("DOIGET_LOG_PATH", root.join("access.jsonl").as_str())
.env("HOME", root.as_str())
.env("USERPROFILE", root.as_str());
cmd
}
#[tokio::test]
#[serial]
async fn tex_source_extracts_logs_and_caches() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/src/2401.12345"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(single_tex_tar_gz()))
.up_to_n_times(1)
.mount(&server)
.await;
let dir = TempDir::new().expect("tempdir");
let root = utf8(&dir);
let cache_root = root.join("cache");
let log_path = root.join("access.jsonl");
let guard = EnvGuard::new(ENV_KEYS);
guard.set("DOIGET_ARXIV_BASE", &server.uri());
guard.set("DOIGET_ARXIV_SRC_BASE", &server.uri());
guard.set("DOIGET_CACHE_ROOT", cache_root.as_str());
guard.set("DOIGET_STORE_ROOT", root.join("papers").as_str());
guard.set("DOIGET_LOG_PATH", log_path.as_str());
guard.set("DOIGET_MODE", "quiet");
guard.set("HOME", root.as_str());
guard.set("USERPROFILE", root.as_str());
let res = run(
"arxiv:2401.12345".to_string(),
None,
false,
OutputMode::Quiet,
true,
)
.await;
assert!(res.is_ok(), "tex-source run failed: {res:?}");
let log = std::fs::read_to_string(log_path.as_std_path()).expect("read log");
assert!(
log.contains("\"event\":\"fetch\"") && log.contains("\"source\":\"arxiv-src\""),
"missing arxiv-src fetch row in:\n{log}"
);
let tex_dir = cache_root.join("tex-src");
let entries: Vec<_> = std::fs::read_dir(tex_dir.as_std_path())
.expect("tex-src cache dir exists")
.filter_map(Result::ok)
.filter(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.map(|x| x == "json")
.unwrap_or(false)
})
.collect();
assert_eq!(
entries.len(),
1,
"exactly one cached tex-src entry expected"
);
let res2 = run(
"arxiv:2401.12345".to_string(),
None,
false,
OutputMode::Quiet,
true,
)
.await;
assert!(
res2.is_ok(),
"second (cached) tex-source run failed: {res2:?}"
);
}
#[tokio::test]
#[serial]
async fn tex_source_for_doi_reports_no_oa_available() {
let dir = TempDir::new().expect("tempdir");
let root = utf8(&dir);
let guard = EnvGuard::new(ENV_KEYS);
guard.set("DOIGET_CACHE_ROOT", root.join("cache").as_str());
guard.set("DOIGET_STORE_ROOT", root.join("papers").as_str());
guard.set("DOIGET_LOG_PATH", root.join("access.jsonl").as_str());
guard.set("DOIGET_MODE", "quiet");
guard.set("HOME", root.as_str());
guard.set("USERPROFILE", root.as_str());
let err = run(
"10.1234/example".to_string(),
None,
false,
OutputMode::Quiet,
true,
)
.await
.expect_err("a DOI must error (no TeX source path for bare DOIs)");
let exit = err
.downcast_ref::<doiget_cli::commands::fetch::CliExit>()
.expect("DOI path must yield a CliExit");
assert_ne!(exit.0, 0, "exit code must be non-zero for a bare DOI");
}
#[tokio::test]
#[serial]
async fn tex_source_pdf_only_exits_non_zero_with_fetch_note() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/src/2012.03644"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.4 fake-pdf".as_slice()))
.mount(&server)
.await;
let dir = TempDir::new().expect("tempdir");
let root = utf8(&dir);
doiget_subprocess(&root, &server.uri())
.args(["tex-source", "arxiv:2012.03644"])
.assert()
.failure()
.stderr(contains("doiget fetch arxiv:2012.03644"));
}
#[tokio::test]
#[serial]
async fn tex_source_piped_non_tty_still_emits_source() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/src/2401.12345"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(single_tex_tar_gz()))
.mount(&server)
.await;
let dir = TempDir::new().expect("tempdir");
let root = utf8(&dir);
doiget_subprocess(&root, &server.uri())
.args(["tex-source", "arxiv:2401.12345"])
.assert()
.success()
.stdout(contains("\\documentclass"))
.stdout(contains("Hello from arXiv."));
}
#[tokio::test]
#[serial]
async fn tex_source_explicit_quiet_suppresses_output() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/src/2401.12345"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(single_tex_tar_gz()))
.mount(&server)
.await;
let dir = TempDir::new().expect("tempdir");
let root = utf8(&dir);
doiget_subprocess(&root, &server.uri())
.args(["tex-source", "arxiv:2401.12345", "--quiet"])
.assert()
.success()
.stdout(predicates::str::is_empty());
}