1use camino::{Utf8Path, Utf8PathBuf};
25use chrono::Utc;
26
27use crate::orchestrator::{cite_metadata, resolve_only, write_metadata_and_pdf};
28use crate::source::{FetchContext, FetchError};
29use crate::store::{DoigetExtension, Metadata, Store, StoreError, ORIGIN_USER_SUPPLIED};
30use crate::{CapabilityProfile, Ref};
31
32#[derive(Debug, Clone)]
34pub struct AddOutcome {
35 pub safekey: String,
37 pub path: Utf8PathBuf,
39 pub size_bytes: u64,
41 pub title: String,
43 pub replaced: bool,
45}
46
47#[derive(Debug, thiserror::Error)]
49pub enum AddError {
50 #[error("{0} is not a regular file")]
52 NotAFile(Utf8PathBuf),
53 #[error("{0} is a symbolic link; pass the file it points to")]
55 Symlink(Utf8PathBuf),
56 #[error("reading {path}: {source}")]
58 Io {
59 path: Utf8PathBuf,
61 #[source]
63 source: std::io::Error,
64 },
65 #[error("{0} does not start with %PDF-, so it is not a PDF")]
67 NotAPdf(Utf8PathBuf),
68 #[error("{path} is {actual} bytes, over the {cap}-byte cap a fetched PDF is held to")]
70 TooLarge {
71 path: Utf8PathBuf,
73 actual: u64,
75 cap: u64,
77 },
78 #[error("the store already holds a PDF for {ref_} at {path}; pass --force to replace it")]
80 AlreadyStored {
81 ref_: String,
83 path: Utf8PathBuf,
85 },
86 #[error(
88 "the file name {stem:?} looks like the id of a different work than {ref_}; \
89 check the download, or pass --force if the name is wrong and the file is right"
90 )]
91 NamesAnotherWork {
92 stem: String,
94 ref_: String,
96 },
97 #[error("resolving {ref_}: {source}")]
99 Resolve {
100 ref_: String,
102 #[source]
104 source: FetchError,
105 },
106 #[error("writing the store: {0}")]
108 Store(#[source] FetchError),
109 #[error("the store entry for {ref_} could not be read ({source}); fix or remove it first")]
112 UnreadableEntry {
113 ref_: String,
115 #[source]
117 source: StoreError,
118 },
119}
120
121#[must_use]
124pub fn file_stem(file: &Utf8Path) -> String {
125 let name = file.file_name().unwrap_or("");
126 let stem = name
127 .len()
128 .checked_sub(4)
129 .filter(|&i| name.is_char_boundary(i) && name[i..].eq_ignore_ascii_case(".pdf"))
130 .map_or(name, |i| &name[..i]);
131 let stem = match stem.rfind(" (") {
132 Some(i)
133 if stem.ends_with(')')
134 && stem[i + 2..stem.len() - 1]
135 .chars()
136 .all(|c| c.is_ascii_digit()) =>
137 {
138 &stem[..i]
139 }
140 _ => stem,
141 };
142 stem.trim().to_string()
143}
144
145#[must_use]
149pub fn stem_names(stem: &str, ref_: &Ref) -> bool {
150 let s = stem.to_lowercase();
151 match ref_ {
152 Ref::Doi(d) => {
153 let doi = d.as_str().to_lowercase();
154 let suffix = doi.split_once('/').map_or(doi.as_str(), |(_, x)| x);
155 s == suffix || s == doi.replace('/', "_") || s == doi.replace('/', "-")
156 }
157 Ref::Arxiv(id) => {
158 let id = id.as_str().to_lowercase().replace('/', "_");
159 let unversioned = match s.rfind('v') {
160 Some(i)
161 if i > 0
162 && i + 1 < s.len()
163 && s[i + 1..].chars().all(|c| c.is_ascii_digit()) =>
164 {
165 &s[..i]
166 }
167 _ => s.as_str(),
168 };
169 unversioned == id
170 || unversioned == format!("arxiv_{id}")
171 || unversioned == format!("arxiv-{id}")
172 }
173 }
174}
175
176#[must_use]
181pub fn looks_like_an_id(stem: &str) -> bool {
182 stem.len() >= 6
183 && stem
184 .chars()
185 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
186 && stem.chars().any(|c| c.is_ascii_digit())
187}
188
189pub fn check_file(file: &Utf8Path) -> Result<Vec<u8>, AddError> {
199 use std::io::Read;
200 let io = |source| AddError::Io {
201 path: file.to_path_buf(),
202 source,
203 };
204 let meta = std::fs::symlink_metadata(file).map_err(io)?;
205 if meta.file_type().is_symlink() {
206 return Err(AddError::Symlink(file.to_path_buf()));
207 }
208 if !meta.is_file() {
209 return Err(AddError::NotAFile(file.to_path_buf()));
210 }
211 let too_large = |actual| AddError::TooLarge {
212 path: file.to_path_buf(),
213 actual,
214 cap: crate::PDF_MAX_BYTES,
215 };
216 if meta.len() > crate::PDF_MAX_BYTES {
217 return Err(too_large(meta.len()));
218 }
219 let f = std::fs::File::open(file).map_err(io)?;
223 let opened = f.metadata().map_err(io)?;
224 if !opened.is_file() || !same_file(&meta, &opened) {
225 return Err(AddError::Symlink(file.to_path_buf()));
226 }
227 let mut bytes = Vec::new();
228 f.take(crate::PDF_MAX_BYTES + 1)
229 .read_to_end(&mut bytes)
230 .map_err(io)?;
231 let len = bytes.len() as u64;
232 if len > crate::PDF_MAX_BYTES {
233 return Err(too_large(len));
234 }
235 if !bytes.starts_with(b"%PDF-") {
236 return Err(AddError::NotAPdf(file.to_path_buf()));
237 }
238 Ok(bytes)
239}
240
241#[cfg(unix)]
243fn same_file(a: &std::fs::Metadata, b: &std::fs::Metadata) -> bool {
244 use std::os::unix::fs::MetadataExt;
245 a.dev() == b.dev() && a.ino() == b.ino()
246}
247
248#[cfg(not(unix))]
251fn same_file(_: &std::fs::Metadata, _: &std::fs::Metadata) -> bool {
252 true
253}
254
255pub async fn add_user_pdf(
265 ref_: &Ref,
266 file: &Utf8Path,
267 force: bool,
268 profile: &CapabilityProfile,
269 ctx: &FetchContext,
270 store: &dyn Store,
271 store_root: &Utf8Path,
272) -> Result<AddOutcome, AddError> {
273 let bytes = check_file(file)?;
274 let size = bytes.len() as u64;
275 let stem = file_stem(file);
276 if !force && looks_like_an_id(&stem) && !stem_names(&stem, ref_) {
277 return Err(AddError::NamesAnotherWork {
278 stem,
279 ref_: ref_.as_input_str().to_string(),
280 });
281 }
282 let safekey = ref_.safekey();
283 let pdf_path = store_root.join(format!("{}.pdf", safekey.as_str()));
284 let replaced = pdf_path.exists();
285 if replaced && !force {
286 return Err(AddError::AlreadyStored {
287 ref_: ref_.as_input_str().to_string(),
288 path: pdf_path,
289 });
290 }
291 let stored = crate::store::blocking_section(|| store.read(&safekey)).map_err(|source| {
292 AddError::UnreadableEntry {
293 ref_: ref_.as_input_str().to_string(),
294 source,
295 }
296 })?;
297 let mut m: Metadata = match stored {
298 Some(m) => m,
299 None => {
300 let outcome =
301 resolve_only(ref_, profile, ctx)
302 .await
303 .map_err(|source| AddError::Resolve {
304 ref_: ref_.as_input_str().to_string(),
305 source,
306 })?;
307 cite_metadata(ref_, &outcome)
308 }
309 };
310 let prior = m.doiget.take();
311 m.pdf_path = Some(format!("{}.pdf", safekey.as_str()));
312 m.doiget = Some(DoigetExtension {
313 fetched_at: Utc::now(),
314 source: "user".to_string(),
315 license: crate::store::metadata::LICENSE_UNDETERMINED.to_string(),
316 oa_status: prior.as_ref().and_then(|d| d.oa_status.clone()),
317 size_bytes: size,
318 mcp_call_id: None,
319 tags: prior.as_ref().map(|d| d.tags.clone()).unwrap_or_default(),
320 collections: prior
321 .as_ref()
322 .map(|d| d.collections.clone())
323 .unwrap_or_default(),
324 annotation: prior.as_ref().and_then(|d| d.annotation.clone()),
325 repaired_fields: prior
326 .as_ref()
327 .map(|d| d.repaired_fields.clone())
328 .unwrap_or_default(),
329 short_venue: prior.as_ref().and_then(|d| d.short_venue.clone()),
330 origin: Some(ORIGIN_USER_SUPPLIED.to_string()),
331 });
332 let staged = crate::orchestrator::stage_pdf_to_tempfile(&bytes).map_err(AddError::Store)?;
333 let staged_path = Utf8Path::from_path(staged.path()).ok_or_else(|| {
334 AddError::Store(FetchError::SourceSchema {
335 hint: "staging tempfile path is not UTF-8".to_string(),
336 })
337 })?;
338 crate::store::blocking_section(|| {
339 write_metadata_and_pdf(store, &safekey, &m, Some(staged_path), ctx)
340 })
341 .map_err(AddError::Store)?;
342 Ok(AddOutcome {
343 safekey: safekey.as_str().to_string(),
344 path: pdf_path,
345 size_bytes: size,
346 title: m.title,
347 replaced,
348 })
349}
350
351#[must_use]
355pub fn match_file<'a>(stem: &str, candidates: &'a [Ref]) -> Option<&'a Ref> {
356 let mut hits = candidates.iter().filter(|r| stem_names(stem, r));
357 let first = hits.next()?;
358 hits.next().is_none().then_some(first)
359}
360
361#[cfg(test)]
362#[allow(clippy::expect_used, clippy::unwrap_used)]
363mod tests {
364 use super::*;
365
366 fn r(s: &str) -> Ref {
367 Ref::parse(s).expect("ref")
368 }
369
370 #[test]
372 fn publisher_download_names_match_their_dois() {
373 for (name, doi) in [
374 ("BF01340294.pdf", "10.1007/BF01340294"),
375 ("PhysRev.34.1293.pdf", "10.1103/PhysRev.34.1293"),
376 ("RevModPhys.23.69.pdf", "10.1103/RevModPhys.23.69"),
377 ("rspa.1950.0036.pdf", "10.1098/rspa.1950.0036"),
378 ("BF01340294 (1).pdf", "10.1007/BF01340294"),
379 ("10.1007_BF01340294.PDF", "10.1007/BF01340294"),
380 ] {
381 let stem = file_stem(Utf8Path::new(name));
382 assert!(stem_names(&stem, &r(doi)), "{name} -> {stem}");
383 }
384 assert!(stem_names("2401.12345v2", &r("arxiv:2401.12345")));
385 assert!(stem_names("cond-mat_0409292", &r("cond-mat/0409292")));
386 }
387
388 #[test]
389 fn a_descriptive_name_makes_no_claim_and_an_id_name_does() {
390 assert!(!looks_like_an_id(&file_stem(Utf8Path::new(
391 "1928-024 PCPS Hartree - The wave mechanics of an atom.pdf"
392 ))));
393 assert!(looks_like_an_id("BF01397394"));
394 assert!(looks_like_an_id("PhysRev.34.1293"));
395 assert!(!looks_like_an_id("thesis"));
396 }
397
398 #[test]
399 fn from_dir_matching_requires_exactly_one_candidate() {
400 let c = vec![r("10.1007/BF01340294"), r("10.1103/PhysRev.34.1293")];
401 assert_eq!(
402 match_file("bf01340294", &c).map(Ref::as_input_str),
403 Some("10.1007/BF01340294")
404 );
405 assert!(match_file("unrelated-2020", &c).is_none());
406 let dup = vec![r("10.1007/X123456"), r("10.9999/X123456")];
407 assert!(match_file("X123456", &dup).is_none());
408 }
409
410 #[test]
411 fn the_file_checks_are_the_ones_a_fetch_makes() {
412 let td = tempfile::TempDir::new().expect("tempdir");
413 let dir = Utf8Path::from_path(td.path()).expect("utf-8");
414 let pdf = dir.join("ok.pdf");
415 std::fs::write(&pdf, b"%PDF-1.4\n...").expect("write");
416 assert_eq!(check_file(&pdf).expect("ok").len(), 12);
417 let html = dir.join("login.pdf");
418 std::fs::write(&html, b"<!doctype html>").expect("write");
419 assert!(matches!(check_file(&html), Err(AddError::NotAPdf(_))));
420 assert!(matches!(check_file(dir), Err(AddError::NotAFile(_))));
421 assert!(matches!(
422 check_file(&dir.join("absent.pdf")),
423 Err(AddError::Io { .. })
424 ));
425 let big = dir.join("big.pdf");
426 let f = std::fs::File::create(&big).expect("create");
427 f.set_len(crate::PDF_MAX_BYTES + 1).expect("sparse");
428 assert!(matches!(check_file(&big), Err(AddError::TooLarge { .. })));
429 #[cfg(unix)]
430 {
431 let link = dir.join("link.pdf");
432 std::os::unix::fs::symlink(&pdf, &link).expect("symlink");
433 assert!(matches!(check_file(&link), Err(AddError::Symlink(_))));
434 }
435 }
436}