Skip to main content

nereids_endf/
retrieval.rs

1//! ENDF file download and local caching.
2//!
3//! Downloads ENDF files from official NNDC/IAEA sources and caches them locally
4//! for offline use. Follows the IAEA URL patterns established by PLEIADES.
5//!
6//! ## PLEIADES Reference
7//! - `pleiades/nuclear/manager.py` — URL construction, cache directory layout
8//! - `pleiades/nuclear/models.py` — library enum, filename patterns
9
10use nereids_core::elements;
11use nereids_core::types::Isotope;
12use std::fs;
13use std::io::Read;
14use std::path::{Path, PathBuf};
15use std::sync::{Mutex, OnceLock};
16use std::time::{Duration, Instant};
17
18const IAEA_BASE_URL: &str = "https://www-nds.iaea.org/public/download-endf";
19const NNDC_ENDF_BASE_URL: &str = "https://www.nndc.bnl.gov/endf-data/ENDF";
20// Polite, identifiable UA: many nuclear-data servers either require a non-default
21// UA or treat default `reqwest` traffic as a bot (IAEA returning
22// HTTP 403 to v0.1.8 batch fetches). Version is derived from `CARGO_PKG_VERSION`
23// so it never drifts from `Cargo.toml`.
24const ENDF_USER_AGENT: &str = concat!(
25    "NEREIDS/",
26    env!("CARGO_PKG_VERSION"),
27    " (https://github.com/ornlneutronimaging/NEREIDS; contact: zhangc@ornl.gov)",
28);
29const IAEA_MIN_REQUEST_INTERVAL: Duration = Duration::from_secs(3);
30
31static LAST_IAEA_REQUEST: OnceLock<Mutex<Option<Instant>>> = OnceLock::new();
32
33/// ENDF evaluated nuclear data libraries, serialized by name.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35pub enum EndfLibrary {
36    /// ENDF/B-VIII.0 (previous release).
37    #[serde(rename = "ENDF/B-VIII.0")]
38    EndfB8_0,
39    /// ENDF/B-VIII.1 (latest release, Aug 2024).
40    #[serde(rename = "ENDF/B-VIII.1")]
41    EndfB8_1,
42    /// JEFF-3.3 (European library).
43    #[serde(rename = "JEFF-3.3")]
44    Jeff3_3,
45    /// JENDL-5 (Japanese library).
46    #[serde(rename = "JENDL-5")]
47    Jendl5,
48    /// TENDL-2023 (TALYS-based, 2,300 ground-state isotopes including activation
49    /// products and transuranics not covered by the major evaluated libraries).
50    #[serde(rename = "TENDL-2023")]
51    Tendl2023,
52    /// CENDL-3.2 (Chinese library, 258 ground-state isotopes plus free neutron;
53    /// Z=1–98 with no Br evaluations — no MAT entry for Br-79 / Br-81, so
54    /// `mat_number(.., EndfLibrary::Cendl3_2)` returns `None` for Br before any
55    /// retrieval call).
56    #[serde(rename = "CENDL-3.2")]
57    Cendl3_2,
58}
59
60impl EndfLibrary {
61    /// The energy windows, in eV, that counts fits and maps using this
62    /// library's evaluation of `isotope` leave out, because its resonance
63    /// energies there disagree with measured transmission.  The resonances'
64    /// wings and the instrument's pulse carry part of a window's counts outside
65    /// it, where at high counts per bin they raise the fit's overdispersion.
66    pub fn excluded_windows_ev(self, isotope: &Isotope) -> &'static [(f64, f64)] {
67        match (self, isotope.z(), isotope.a()) {
68            (Self::EndfB8_1, 73, 181) => &[(13.6, 14.2)],
69            _ => &[],
70        }
71    }
72
73    /// URL path component for this library.
74    fn url_path(&self) -> &'static str {
75        match self {
76            Self::EndfB8_0 => "ENDF-B-VIII.0/n",
77            Self::EndfB8_1 => "ENDF-B-VIII.1/n",
78            Self::Jeff3_3 => "JEFF-3.3/n",
79            Self::Jendl5 => "JENDL-5/n",
80            Self::Tendl2023 => "TENDL-2023/n",
81            Self::Cendl3_2 => "CENDL-3.2/n",
82        }
83    }
84
85    /// Cache directory name.
86    fn cache_dir_name(&self) -> &'static str {
87        match self {
88            Self::EndfB8_0 => "ENDF-B-VIII.0",
89            Self::EndfB8_1 => "ENDF-B-VIII.1",
90            Self::Jeff3_3 => "JEFF-3.3",
91            Self::Jendl5 => "JENDL-5",
92            Self::Tendl2023 => "TENDL-2023",
93            Self::Cendl3_2 => "CENDL-3.2",
94        }
95    }
96
97    /// Construct the ZIP filename for a given isotope.
98    ///
99    /// IAEA uses two naming conventions (MAT always 4-digit zero-padded):
100    /// - VIII.0, JEFF-3.3: MAT-first `n_{mat:04}_{z}-{Sym}-{a}.zip` (Z unpadded)
101    /// - VIII.1, JENDL-5, TENDL-2023, CENDL-3.2: Z-first
102    ///   `n_{z:03}-{Sym}-{a}_{mat:04}.zip` (Z 3-digit; free neutron uses `nn`)
103    fn zip_filename(&self, isotope: &Isotope, mat: u32) -> String {
104        let sym = elements::element_symbol(isotope.z()).unwrap_or("X");
105        let z = isotope.z();
106        let a = isotope.a();
107        match self {
108            Self::EndfB8_0 | Self::Jeff3_3 => {
109                format!("n_{mat:04}_{z}-{sym}-{a}.zip")
110            }
111            Self::EndfB8_1 | Self::Jendl5 | Self::Tendl2023 | Self::Cendl3_2 => {
112                let zip_sym = if z == 0 && a == 1 { "nn" } else { sym };
113                format!("n_{z:03}-{zip_sym}-{a}_{mat:04}.zip")
114            }
115        }
116    }
117}
118
119/// Compute the default on-disk cache directory for a given library without
120/// constructing an [`EndfRetriever`].
121///
122/// The retriever's constructor builds a `reqwest` blocking client + TLS
123/// configuration, which is wasted work when all the caller needs is the cache
124/// path for a UI hint or manual drop instruction. Mirrors the path layout
125/// that [`EndfRetriever::new`] would resolve to.
126pub fn default_cache_dir(library: EndfLibrary) -> PathBuf {
127    default_cache_root().join(library.cache_dir_name())
128}
129
130/// Compute the default cache file path for an isotope without constructing
131/// an [`EndfRetriever`]. Same layout as [`EndfRetriever::cache_file_path`].
132pub fn default_cache_file_path(isotope: &Isotope, library: EndfLibrary) -> PathBuf {
133    let sym = elements::element_symbol(isotope.z()).unwrap_or("X");
134    default_cache_dir(library).join(format!("{}-{}.endf", sym, isotope.a()))
135}
136
137fn default_cache_root() -> PathBuf {
138    dirs::cache_dir()
139        .unwrap_or_else(|| PathBuf::from(".cache"))
140        .join("nereids")
141        .join("endf")
142}
143
144/// ENDF file retrieval manager with local caching.
145pub struct EndfRetriever {
146    /// Root cache directory.
147    cache_root: PathBuf,
148    /// Base URL for IAEA downloads used by libraries that are not mirrored by
149    /// NNDC as raw ENDF-6 files.
150    base_url: String,
151    /// Shared HTTP client with explicit connect/total timeouts so a transport
152    /// stall surfaces as a clear error instead of hanging the GUI worker.
153    client: reqwest::blocking::Client,
154}
155
156impl EndfRetriever {
157    /// Create a new retriever with default cache location (~/.cache/nereids/endf/).
158    pub fn new() -> Self {
159        Self {
160            cache_root: default_cache_root(),
161            base_url: IAEA_BASE_URL.to_string(),
162            client: build_http_client(),
163        }
164    }
165
166    /// Create a retriever with a custom cache directory.
167    pub fn with_cache_dir(cache_dir: impl Into<PathBuf>) -> Self {
168        Self {
169            cache_root: cache_dir.into(),
170            base_url: IAEA_BASE_URL.to_string(),
171            client: build_http_client(),
172        }
173    }
174
175    /// Get the cache directory for a specific library.
176    ///
177    /// Public so the GUI can show users exactly where to drop a manually-
178    /// downloaded ENDF file when a fetch fails.
179    pub fn cache_dir(&self, library: EndfLibrary) -> PathBuf {
180        self.cache_root.join(library.cache_dir_name())
181    }
182
183    /// Get the cached ENDF file path for an isotope.
184    ///
185    /// Public so callers can present the exact target path for manual file
186    /// drops; see [`Self::install_local_endf`] for the programmatic equivalent.
187    pub fn cache_file_path(&self, isotope: &Isotope, library: EndfLibrary) -> PathBuf {
188        let sym = elements::element_symbol(isotope.z()).unwrap_or("X");
189        let filename = format!("{}-{}.endf", sym, isotope.a());
190        self.cache_dir(library).join(filename)
191    }
192
193    /// Retrieve the ENDF file for an isotope, using cache if available.
194    ///
195    /// Returns the path to the cached ENDF file and its contents as a string.
196    ///
197    /// # Arguments
198    /// * `isotope` — The isotope to retrieve data for.
199    /// * `library` — The ENDF library to use.
200    /// * `mat` — The ENDF MAT (material) number.
201    pub fn get_endf_file(
202        &self,
203        isotope: &Isotope,
204        library: EndfLibrary,
205        mat: u32,
206    ) -> Result<(PathBuf, String), EndfRetrievalError> {
207        let cache_path = self.cache_file_path(isotope, library);
208
209        // Check cache first.
210        if cache_path.exists() {
211            let contents = fs::read_to_string(&cache_path)?;
212            return Ok((cache_path, contents));
213        }
214
215        // Download from the remote source.
216        let contents = self.download_endf(isotope, library, mat)?;
217
218        // Cache the file.
219        if let Some(parent) = cache_path.parent() {
220            fs::create_dir_all(parent)?;
221        }
222        fs::write(&cache_path, &contents)?;
223
224        Ok((cache_path, contents))
225    }
226
227    /// Download an ENDF file from NNDC raw files or IAEA ZIP archives.
228    fn download_endf(
229        &self,
230        isotope: &Isotope,
231        library: EndfLibrary,
232        mat: u32,
233    ) -> Result<String, EndfRetrievalError> {
234        let nndc_url = nndc_endf_url(isotope, library);
235        let nndc_already_tried = if let Some(primary_url) = &nndc_url {
236            if let Ok(text) = self.fetch_text(primary_url, false) {
237                return Ok(text);
238            }
239            true
240        } else {
241            false
242        };
243
244        let zip_filename = library.zip_filename(isotope, mat);
245        let url = format!("{}/{}/{}", self.base_url, library.url_path(), zip_filename);
246        let iaea_result = self.fetch_bytes(&url, true);
247        match iaea_result {
248            Ok(bytes) => extract_endf_from_zip(&bytes),
249            Err(err) if should_try_nndc_fallback(&err) => {
250                if !nndc_already_tried
251                    && let Some(fallback_url) = &nndc_url
252                    && let Ok(text) = self.fetch_text(fallback_url, false)
253                {
254                    return Ok(text);
255                }
256                Err(err.into_retrieval_error(isotope, library))
257            }
258            Err(err) => Err(err.into_retrieval_error(isotope, library)),
259        }
260    }
261
262    fn fetch_bytes(&self, url: &str, pace_iaea: bool) -> Result<Vec<u8>, DownloadError> {
263        if pace_iaea {
264            wait_for_iaea_slot();
265        }
266        let response = self
267            .client
268            .get(url)
269            .send()
270            .map_err(|e| DownloadError::Transport {
271                url: url.to_string(),
272                message: format_error_chain(&e),
273            })?;
274
275        let status = response.status();
276        if !status.is_success() {
277            return Err(DownloadError::Http {
278                url: url.to_string(),
279                status,
280                cloudflare_challenge: has_cloudflare_challenge(&response),
281            });
282        }
283
284        response
285            .bytes()
286            .map(|bytes| bytes.to_vec())
287            .map_err(|e| DownloadError::Transport {
288                url: url.to_string(),
289                message: format!("Failed to read response body: {}", format_error_chain(&e)),
290            })
291    }
292
293    fn fetch_text(&self, url: &str, pace_iaea: bool) -> Result<String, EndfRetrievalError> {
294        let bytes = self
295            .fetch_bytes(url, pace_iaea)
296            .map_err(|err| err.into_retrieval_error_for_url())?;
297        String::from_utf8(bytes)
298            .map_err(|e| EndfRetrievalError::Parse(format!("Invalid UTF-8 ENDF response: {e}")))
299    }
300
301    /// Peek a user-supplied ENDF source: decode the body and parse the HEAD
302    /// record so the caller can route the upload to the correct isotope entry.
303    ///
304    /// Accepts the same input forms as [`Self::install_local_endf`] — a raw
305    /// ENDF text file or the IAEA ZIP archive distribution — and returns the
306    /// isotope declared by the file's MF=2 MT=151 HEAD record alongside the
307    /// decoded text. The GUI uses this to dispatch a manual upload to the
308    /// matching `IsotopeEntry` without re-reading or re-extracting the file
309    /// during install (avoid N-pass zip extraction).
310    pub fn peek_local_endf(source: &Path) -> Result<(Isotope, String), EndfRetrievalError> {
311        let raw = fs::read(source)?;
312        let endf_text = if looks_like_zip(source, &raw) {
313            extract_endf_from_zip(&raw)?
314        } else {
315            String::from_utf8(raw).map_err(|e| {
316                EndfRetrievalError::Parse(format!("ENDF file is not valid UTF-8: {e}"))
317            })?
318        };
319        let parsed = crate::parser::parse_endf_file2(&endf_text)
320            .map_err(|e| EndfRetrievalError::Parse(format!("Failed to parse ENDF file: {e}")))?;
321        Ok((parsed.isotope, endf_text))
322    }
323
324    /// Write already-validated ENDF text to the canonical cache slot for
325    /// `isotope`/`library`. Returns the cache file path.
326    ///
327    /// Use this when the caller has already obtained `text` via
328    /// [`Self::peek_local_endf`] and confirmed the isotope. For one-shot
329    /// install-from-path, use [`Self::install_local_endf`] instead.
330    pub fn install_endf_text(
331        &self,
332        isotope: &Isotope,
333        library: EndfLibrary,
334        text: &str,
335    ) -> Result<PathBuf, EndfRetrievalError> {
336        let cache_path = self.cache_file_path(isotope, library);
337        if let Some(parent) = cache_path.parent() {
338            fs::create_dir_all(parent)?;
339        }
340        fs::write(&cache_path, text)?;
341        Ok(cache_path)
342    }
343
344    /// Install a user-supplied ENDF file into the cache for `isotope`/`library`.
345    ///
346    /// Accepts either a raw ENDF text file (`.endf`, `.dat`, `.txt`, or
347    /// extensionless) or the IAEA ZIP archive distribution (`n_…_….zip`).
348    /// The file is parsed to verify that its declared isotope matches
349    /// `isotope`; on success the text is written to the canonical cache slot
350    /// returned by [`Self::cache_file_path`] and `(cache_path, text)` is
351    /// returned. Subsequent calls to [`Self::get_endf_file`] will then hit the
352    /// cache without any network access.
353    ///
354    /// This is the GUI's manual-upload escape hatch for users on networks
355    /// where IAEA/NNDC is blocked.
356    pub fn install_local_endf(
357        &self,
358        isotope: &Isotope,
359        library: EndfLibrary,
360        source: &Path,
361    ) -> Result<(PathBuf, String), EndfRetrievalError> {
362        let (found, endf_text) = Self::peek_local_endf(source)?;
363        if found != *isotope {
364            return Err(EndfRetrievalError::IsotopeMismatch {
365                expected: isotope_label(isotope),
366                found: isotope_label(&found),
367            });
368        }
369        let cache_path = self.install_endf_text(isotope, library, &endf_text)?;
370        Ok((cache_path, endf_text))
371    }
372}
373
374impl Default for EndfRetriever {
375    fn default() -> Self {
376        Self::new()
377    }
378}
379
380/// Look up the ENDF MAT number for a ground-state isotope, library-aware.
381///
382/// Dispatches to the underlying `endf-mat` table for the requested library:
383/// - `Tendl2023`: ~2,300 ground-state isotopes from the TENDL-2023 neutrons sublibrary.
384/// - `Cendl3_2`: 258 isotopes plus free neutron from the CENDL-3.2 neutrons sublibrary (no Br entries).
385/// - All other variants: 535 isotopes from the ENDF/B-VIII.0 neutrons sublibrary
386///   (the MAT numbers in ENDF/B-VIII.1, JEFF-3.3, and JENDL-5 are identical to
387///   ENDF/B-VIII.0 for the isotopes they share).
388///
389/// MAT numbers are *almost* universal across libraries; the one documented exception
390/// is Es-255, which is MAT 9916 in ENDF/B-VIII.0 and MAT 9915 in TENDL-2023. CENDL-3.2
391/// has no MAT divergences from ENDF/B-VIII.0 for shared isotopes. The library-aware
392/// lookup ensures the correct MAT is used to construct retrieval URLs.
393pub fn mat_number(isotope: &Isotope, library: EndfLibrary) -> Option<u32> {
394    match library {
395        EndfLibrary::Tendl2023 => endf_mat::mat_number_tendl(isotope.z(), isotope.a()),
396        EndfLibrary::Cendl3_2 => endf_mat::mat_number_cendl(isotope.z(), isotope.a()),
397        _ => endf_mat::mat_number(isotope.z(), isotope.a()),
398    }
399}
400
401/// All mass numbers with an evaluation for element Z in the given library.
402///
403/// Library-aware counterpart to [`endf_mat::known_isotopes`] (which is
404/// ENDF/B-VIII.0-only) — must be used wherever the GUI surfaces the set of
405/// selectable isotopes for the *currently selected* library, otherwise
406/// TENDL-2023-only isotopes (e.g. Fm-247) will be silently hidden, and Br
407/// will be incorrectly shown as available under CENDL-3.2.
408pub fn known_isotopes_for(z: u32, library: EndfLibrary) -> Vec<u32> {
409    match library {
410        EndfLibrary::Tendl2023 => endf_mat::known_isotopes_tendl(z),
411        EndfLibrary::Cendl3_2 => endf_mat::known_isotopes_cendl(z),
412        _ => endf_mat::known_isotopes(z),
413    }
414}
415
416/// Whether the given library has an evaluation for `(Z, A)`.
417///
418/// Library-aware counterpart to [`endf_mat::has_endf_evaluation`] — must be
419/// used by GUI availability indicators that depend on the *currently
420/// selected* library.
421pub fn has_endf_evaluation_for(z: u32, a: u32, library: EndfLibrary) -> bool {
422    match library {
423        EndfLibrary::Tendl2023 => endf_mat::has_endf_evaluation_tendl(z, a),
424        EndfLibrary::Cendl3_2 => endf_mat::has_endf_evaluation_cendl(z, a),
425        _ => endf_mat::has_endf_evaluation(z, a),
426    }
427}
428
429/// Errors from ENDF retrieval operations.
430#[derive(Debug, thiserror::Error)]
431pub enum EndfRetrievalError {
432    /// Transport-level failure (connection refused, DNS error, non-404 HTTP error, etc.).
433    #[error("Network error: {0}")]
434    NetworkError(String),
435
436    /// Upstream server actively blocked automated retrieval.
437    #[error("Remote access blocked: HTTP {status} for {url}. {message}")]
438    RemoteAccessBlocked {
439        status: u16,
440        url: String,
441        message: String,
442    },
443
444    /// The isotope exists in ENDF/B-VIII.0 but is not available in the requested library.
445    #[error("{isotope} is not available in the {library} library")]
446    NotInLibrary { isotope: String, library: String },
447
448    /// A user-supplied ENDF file did not describe the isotope it was being
449    /// installed against (manual upload path).
450    #[error("ENDF file is for {found}, but expected {expected}")]
451    IsotopeMismatch { expected: String, found: String },
452
453    #[error("Parse error: {0}")]
454    Parse(String),
455
456    #[error("I/O error: {0}")]
457    Io(#[from] std::io::Error),
458
459    #[error("Isotope not found in MAT database: {0}")]
460    UnknownIsotope(String),
461}
462
463impl EndfRetrievalError {
464    /// Whether this error means the upstream server is denying automated access.
465    pub fn is_remote_access_blocked(&self) -> bool {
466        matches!(self, Self::RemoteAccessBlocked { .. })
467    }
468
469    pub fn is_unavailable(&self) -> bool {
470        match self {
471            Self::NetworkError(_) | Self::RemoteAccessBlocked { .. } => true,
472            Self::NotInLibrary { .. }
473            | Self::IsotopeMismatch { .. }
474            | Self::Parse(_)
475            | Self::Io(_)
476            | Self::UnknownIsotope(_) => false,
477        }
478    }
479}
480
481#[derive(Debug)]
482enum DownloadError {
483    Http {
484        url: String,
485        status: reqwest::StatusCode,
486        cloudflare_challenge: bool,
487    },
488    Transport {
489        url: String,
490        message: String,
491    },
492}
493
494impl DownloadError {
495    fn into_retrieval_error(self, isotope: &Isotope, library: EndfLibrary) -> EndfRetrievalError {
496        match self {
497            Self::Http { status, .. } if status == reqwest::StatusCode::NOT_FOUND => {
498                EndfRetrievalError::NotInLibrary {
499                    isotope: isotope_label(isotope),
500                    library: library.cache_dir_name().to_string(),
501                }
502            }
503            other => other.into_retrieval_error_for_url(),
504        }
505    }
506
507    fn into_retrieval_error_for_url(self) -> EndfRetrievalError {
508        match self {
509            Self::Http {
510                url,
511                status,
512                cloudflare_challenge,
513            } if is_access_block_status(status) => EndfRetrievalError::RemoteAccessBlocked {
514                status: status.as_u16(),
515                url,
516                message: if cloudflare_challenge {
517                    "The server returned a Cloudflare managed challenge; stop batch fetches and retry later from a normal browser/network."
518                        .to_string()
519                } else {
520                    "The upstream server denied automated access; stop batch fetches and retry later."
521                        .to_string()
522                },
523            },
524            Self::Http { url, status, .. } => {
525                EndfRetrievalError::NetworkError(format!("HTTP {status} for {url}"))
526            }
527            Self::Transport { url, message } => {
528                EndfRetrievalError::NetworkError(format!("Failed to fetch {url}: {message}"))
529            }
530        }
531    }
532}
533
534/// Extract the ENDF data file body from a ZIP archive.
535///
536/// Prefers archive entries ending in `.endf`, `.dat`, or `.txt`; falls back to
537/// the first entry if none match. IAEA distributes one ENDF per zip, so the
538/// fallback effectively only fires on hand-rolled archives.
539fn extract_endf_from_zip(zip_bytes: &[u8]) -> Result<String, EndfRetrievalError> {
540    let cursor = std::io::Cursor::new(zip_bytes);
541    let mut archive = zip::ZipArchive::new(cursor)
542        .map_err(|e| EndfRetrievalError::Parse(format!("Invalid ZIP archive: {}", e)))?;
543
544    for i in 0..archive.len() {
545        let mut file = archive
546            .by_index(i)
547            .map_err(|e| EndfRetrievalError::Parse(format!("Failed to read ZIP entry: {}", e)))?;
548        let name = file
549            .name()
550            .map_err(|e| EndfRetrievalError::Parse(format!("Failed to read ZIP entry: {}", e)))?
551            .to_lowercase();
552        if name.ends_with(".endf") || name.ends_with(".dat") || name.ends_with(".txt") {
553            let mut contents = String::new();
554            file.read_to_string(&mut contents).map_err(|e| {
555                EndfRetrievalError::Parse(format!("Failed to read ENDF content: {}", e))
556            })?;
557            return Ok(contents);
558        }
559    }
560
561    if !archive.is_empty() {
562        let mut file = archive
563            .by_index(0)
564            .map_err(|e| EndfRetrievalError::Parse(format!("Failed to read ZIP entry: {}", e)))?;
565        let mut contents = String::new();
566        file.read_to_string(&mut contents).map_err(|e| {
567            EndfRetrievalError::Parse(format!("Failed to read ENDF content: {}", e))
568        })?;
569        return Ok(contents);
570    }
571
572    Err(EndfRetrievalError::Parse(
573        "No ENDF data file found in ZIP archive".to_string(),
574    ))
575}
576
577/// Detect a ZIP archive by extension or PK magic bytes.
578///
579/// IAEA-distributed `n_…_….zip` files always start with `PK\x03\x04`. The
580/// magic-byte check covers the case where a user has stripped or renamed the
581/// extension before uploading.
582fn looks_like_zip(source: &Path, raw: &[u8]) -> bool {
583    let by_ext = source
584        .extension()
585        .and_then(|s| s.to_str())
586        .is_some_and(|ext| ext.eq_ignore_ascii_case("zip"));
587    let by_magic = raw.len() >= 4 && &raw[..4] == b"PK\x03\x04";
588    by_ext || by_magic
589}
590
591/// Build the shared HTTP client used for ENDF downloads.
592///
593/// Connect timeout is short so DNS/TLS failures surface fast; total timeout
594/// is generous because some library zips are several hundred KB over slow
595/// links. ENDF zip files top out around ~1 MB.
596fn build_http_client() -> reqwest::blocking::Client {
597    reqwest::blocking::Client::builder()
598        .user_agent(ENDF_USER_AGENT)
599        .connect_timeout(Duration::from_secs(15))
600        .timeout(Duration::from_secs(60))
601        .build()
602        .expect("failed to build reqwest blocking client")
603}
604
605fn wait_for_iaea_slot() {
606    let mut last_request = LAST_IAEA_REQUEST
607        .get_or_init(|| Mutex::new(None))
608        .lock()
609        .expect("IAEA request throttle mutex poisoned");
610    if let Some(last) = *last_request {
611        let elapsed = last.elapsed();
612        if elapsed < IAEA_MIN_REQUEST_INTERVAL {
613            std::thread::sleep(IAEA_MIN_REQUEST_INTERVAL - elapsed);
614        }
615    }
616    *last_request = Some(Instant::now());
617}
618
619fn nndc_endf_url(isotope: &Isotope, library: EndfLibrary) -> Option<String> {
620    let version = match library {
621        EndfLibrary::EndfB8_0 => "ENDF-B-VIII.0",
622        EndfLibrary::EndfB8_1 => "ENDF-B-VIII.1",
623        _ => return None,
624    };
625    let sym = elements::element_symbol(isotope.z())?;
626    Some(format!(
627        "{NNDC_ENDF_BASE_URL}/{version}/n-{z:03}_{sym}_{a:03}.endf",
628        z = isotope.z(),
629        a = isotope.a()
630    ))
631}
632
633fn should_try_nndc_fallback(err: &DownloadError) -> bool {
634    match err {
635        DownloadError::Http { status, .. } => {
636            *status == reqwest::StatusCode::NOT_FOUND || is_access_block_status(*status)
637        }
638        DownloadError::Transport { .. } => true,
639    }
640}
641
642fn is_access_block_status(status: reqwest::StatusCode) -> bool {
643    status == reqwest::StatusCode::FORBIDDEN
644        || status == reqwest::StatusCode::TOO_MANY_REQUESTS
645        || status == reqwest::StatusCode::SERVICE_UNAVAILABLE
646}
647
648fn has_cloudflare_challenge(response: &reqwest::blocking::Response) -> bool {
649    response
650        .headers()
651        .get("cf-mitigated")
652        .and_then(|value| value.to_str().ok())
653        .is_some_and(|value| value.eq_ignore_ascii_case("challenge"))
654}
655
656fn isotope_label(isotope: &Isotope) -> String {
657    format!(
658        "{}-{}",
659        nereids_core::elements::element_symbol(isotope.z()).unwrap_or("?"),
660        isotope.a()
661    )
662}
663
664/// Render an error and its full `source()` chain on one line. reqwest's outer
665/// `Display` is uninformative ("error sending request for url ...") — the
666/// real cause (TLS, DNS, refused, timeout) lives in the source chain.
667fn format_error_chain(err: &dyn std::error::Error) -> String {
668    let mut out = err.to_string();
669    let mut cur = err.source();
670    while let Some(s) = cur {
671        out.push_str(": ");
672        out.push_str(&s.to_string());
673        cur = s.source();
674    }
675    out
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681
682    #[test]
683    fn cendl_neutron_uses_upstream_nn_filename() {
684        let neutron = Isotope::new(0, 1).unwrap();
685        assert_eq!(
686            EndfLibrary::Cendl3_2.zip_filename(&neutron, 25),
687            "n_000-nn-1_0025.zip"
688        );
689    }
690
691    #[test]
692    fn cendl_neutron_has_library_aware_mat_lookup() {
693        let neutron = Isotope::new(0, 1).unwrap();
694        assert_eq!(mat_number(&neutron, EndfLibrary::Cendl3_2), Some(25));
695    }
696
697    #[test]
698    fn nndc_fallback_url_uses_raw_endf_naming() {
699        let ba138 = Isotope::new(56, 138).unwrap();
700        assert_eq!(
701            nndc_endf_url(&ba138, EndfLibrary::EndfB8_1).as_deref(),
702            Some("https://www.nndc.bnl.gov/endf-data/ENDF/ENDF-B-VIII.1/n-056_Ba_138.endf")
703        );
704        assert!(nndc_endf_url(&ba138, EndfLibrary::Cendl3_2).is_none());
705
706        let fe56 = Isotope::new(26, 56).unwrap();
707        assert_eq!(
708            nndc_endf_url(&fe56, EndfLibrary::EndfB8_1).as_deref(),
709            Some("https://www.nndc.bnl.gov/endf-data/ENDF/ENDF-B-VIII.1/n-026_Fe_056.endf")
710        );
711        let h1 = Isotope::new(1, 1).unwrap();
712        assert_eq!(
713            nndc_endf_url(&h1, EndfLibrary::EndfB8_0).as_deref(),
714            Some("https://www.nndc.bnl.gov/endf-data/ENDF/ENDF-B-VIII.0/n-001_H_001.endf")
715        );
716    }
717
718    #[test]
719    fn iaea_zip_filename_keeps_mass_unpadded() {
720        let fe56 = Isotope::new(26, 56).unwrap();
721        assert_eq!(
722            EndfLibrary::EndfB8_1.zip_filename(&fe56, 2631),
723            "n_026-Fe-56_2631.zip"
724        );
725        assert_eq!(
726            EndfLibrary::EndfB8_0.zip_filename(&fe56, 2631),
727            "n_2631_26-Fe-56.zip"
728        );
729    }
730
731    #[test]
732    fn remote_access_blocked_is_identifiable() {
733        let err = EndfRetrievalError::RemoteAccessBlocked {
734            status: 403,
735            url: "https://example.test/file.zip".into(),
736            message: "blocked".into(),
737        };
738        assert!(err.is_remote_access_blocked());
739        assert!(err.is_unavailable());
740        assert!(EndfRetrievalError::NetworkError("refused".into()).is_unavailable());
741        let absent = EndfRetrievalError::NotInLibrary {
742            isotope: "Fe-56".into(),
743            library: "endf8.1".into(),
744        };
745        assert!(!absent.is_unavailable());
746        assert!(!EndfRetrievalError::Io(std::io::Error::other("disk")).is_unavailable());
747    }
748
749    /// the polite User-Agent must carry the live package version
750    /// and the contact metadata the IAEA acceptance criterion calls for.
751    #[test]
752    fn endf_user_agent_contains_version_and_contact() {
753        assert!(
754            ENDF_USER_AGENT.starts_with("NEREIDS/"),
755            "UA must start with NEREIDS/, got {ENDF_USER_AGENT:?}"
756        );
757        assert!(
758            ENDF_USER_AGENT.contains(env!("CARGO_PKG_VERSION")),
759            "UA must carry CARGO_PKG_VERSION, got {ENDF_USER_AGENT:?}"
760        );
761        assert!(
762            ENDF_USER_AGENT.contains("github.com/ornlneutronimaging/NEREIDS"),
763            "UA must include the project URL, got {ENDF_USER_AGENT:?}"
764        );
765        assert!(
766            ENDF_USER_AGENT.contains("contact: zhangc@ornl.gov"),
767            "UA must include a contact mailbox, got {ENDF_USER_AGENT:?}"
768        );
769    }
770
771    // Minimal valid ENDF MF=2/MT=151 fixture tagged W-184 (ZA=74184), kept
772    // inline so retrieval tests stay self-contained. These tests exercise
773    // install/zip/peek mechanics only, so the body is a synthetic evaluable
774    // MLBW (LRF=2) range — the real W-184 evaluation is LRF=7-only, which
775    // the parser now rejects as carrying no evaluable ranges, and a fixture
776    // that cannot parse would fail isotope extraction here.
777    // Layout: HEAD, isotope CONT (NER=1), range CONT (LRU=1/LRF=2),
778    // SPI/AP CONT (NLS=1), L CONT (L=0, NRS=1), one resonance LIST row
779    // [ER, AJ, GT, GN, GG, GF].
780    const W184_FIXTURE: &str = concat!(
781        " 7.418400+4 1.820000+2          0          0          1          07437 2151    1\n",
782        " 7.418400+4 1.000000+0          0          0          1          07437 2151    2\n",
783        " 1.000000-5 1.000000+3          1          2          0          07437 2151    3\n",
784        " 0.000000+0 7.000000-1          0          0          1          07437 2151    4\n",
785        " 1.820000+2 0.000000+0          0          0          6          17437 2151    5\n",
786        " 1.000000+1 5.000000-1 2.600000-2 1.000000-3 2.500000-2 0.000000+07437 2151    6\n",
787    );
788
789    fn write_zip_with_endf(zip_path: &Path, inner_name: &str, body: &str) -> std::io::Result<()> {
790        let file = fs::File::create(zip_path)?;
791        let mut zw = zip::ZipWriter::new(file);
792        zw.start_file::<_, ()>(inner_name, zip::write::SimpleFileOptions::default())
793            .map_err(|e| std::io::Error::other(format!("zip start_file: {e}")))?;
794        std::io::Write::write_all(&mut zw, body.as_bytes())?;
795        zw.finish()
796            .map_err(|e| std::io::Error::other(format!("zip finish: {e}")))?;
797        Ok(())
798    }
799
800    #[test]
801    fn install_local_endf_accepts_raw_endf_with_matching_isotope() {
802        let cache = tempfile::tempdir().expect("tempdir");
803        let src = tempfile::NamedTempFile::new().expect("src file");
804        std::fs::write(src.path(), W184_FIXTURE).unwrap();
805
806        let retriever = EndfRetriever::with_cache_dir(cache.path());
807        let w184 = Isotope::new(74, 184).unwrap();
808        let (cache_path, text) = retriever
809            .install_local_endf(&w184, EndfLibrary::EndfB8_0, src.path())
810            .expect("install must succeed");
811
812        assert!(cache_path.exists(), "cache file must be written");
813        assert!(text.contains("7.418400+4"), "returned text matches input");
814        // Subsequent get_endf_file calls must read from cache (no network).
815        let (cached_path, cached_text) = retriever
816            .get_endf_file(&w184, EndfLibrary::EndfB8_0, 7437)
817            .expect("cache hit must succeed offline");
818        assert_eq!(cached_path, cache_path);
819        assert_eq!(cached_text, text);
820    }
821
822    #[test]
823    fn install_local_endf_accepts_zip_archive() {
824        let cache = tempfile::tempdir().expect("tempdir");
825        let zip_path = cache.path().join("upload.zip");
826        write_zip_with_endf(&zip_path, "n_7437_74-W-184.endf", W184_FIXTURE)
827            .expect("write zip fixture");
828
829        let retriever = EndfRetriever::with_cache_dir(cache.path());
830        let w184 = Isotope::new(74, 184).unwrap();
831        let (cache_path, _) = retriever
832            .install_local_endf(&w184, EndfLibrary::EndfB8_0, &zip_path)
833            .expect("zip install must succeed");
834        assert!(cache_path.exists());
835        assert_eq!(
836            fs::read_to_string(&cache_path).unwrap(),
837            W184_FIXTURE,
838            "cache must hold the extracted body, not the zip"
839        );
840    }
841
842    /// `default_cache_dir` / `default_cache_file_path` must agree with the
843    /// retriever's instance methods, otherwise UI hints would point users to
844    /// the wrong location after a fetch failure.
845    #[test]
846    fn default_cache_paths_agree_with_retriever_instance() {
847        let retriever = EndfRetriever::new();
848        let w184 = Isotope::new(74, 184).unwrap();
849        for lib in [
850            EndfLibrary::EndfB8_0,
851            EndfLibrary::EndfB8_1,
852            EndfLibrary::Jeff3_3,
853            EndfLibrary::Jendl5,
854            EndfLibrary::Tendl2023,
855            EndfLibrary::Cendl3_2,
856        ] {
857            assert_eq!(default_cache_dir(lib), retriever.cache_dir(lib));
858            assert_eq!(
859                default_cache_file_path(&w184, lib),
860                retriever.cache_file_path(&w184, lib)
861            );
862        }
863    }
864
865    #[test]
866    fn peek_local_endf_extracts_isotope_from_zip_and_raw() {
867        let dir = tempfile::tempdir().expect("tempdir");
868
869        // Raw .endf path
870        let raw_path = dir.path().join("W-184.endf");
871        std::fs::write(&raw_path, W184_FIXTURE).unwrap();
872        let (iso, text) = EndfRetriever::peek_local_endf(&raw_path).expect("raw peek");
873        assert_eq!(iso, Isotope::new(74, 184).unwrap());
874        assert_eq!(text, W184_FIXTURE);
875
876        // Zip path
877        let zip_path = dir.path().join("upload.zip");
878        write_zip_with_endf(&zip_path, "inner.endf", W184_FIXTURE).expect("zip");
879        let (iso_z, text_z) = EndfRetriever::peek_local_endf(&zip_path).expect("zip peek");
880        assert_eq!(iso_z, Isotope::new(74, 184).unwrap());
881        assert_eq!(text_z, W184_FIXTURE);
882    }
883
884    /// Regression for the `looks_like_zip` magic-byte branch: an upload whose
885    /// path has no `.zip` extension must still be detected via `PK\x03\x04`
886    /// and routed through the zip extractor. Without this test, the magic-byte
887    /// branch could silently regress to extension-only detection.
888    #[test]
889    fn install_local_endf_accepts_extensionless_zip_via_magic_bytes() {
890        let dir = tempfile::tempdir().expect("tempdir");
891        let zip_path = dir.path().join("upload-no-extension"); // no .zip suffix
892        write_zip_with_endf(&zip_path, "inner.endf", W184_FIXTURE).expect("write zip");
893
894        // Sanity-check we really did create a zip without the .zip extension.
895        assert!(zip_path.extension().is_none());
896        let head = std::fs::read(&zip_path).unwrap();
897        assert_eq!(&head[..4], b"PK\x03\x04");
898
899        let retriever = EndfRetriever::with_cache_dir(dir.path().join("cache"));
900        let w184 = Isotope::new(74, 184).unwrap();
901        let (cache_path, text) = retriever
902            .install_local_endf(&w184, EndfLibrary::EndfB8_0, &zip_path)
903            .expect("extensionless zip must still install");
904        assert!(cache_path.exists());
905        assert_eq!(text, W184_FIXTURE);
906
907        // peek_local_endf must also handle the extensionless zip.
908        let (iso, peeked_text) =
909            EndfRetriever::peek_local_endf(&zip_path).expect("peek must also work");
910        assert_eq!(iso, w184);
911        assert_eq!(peeked_text, W184_FIXTURE);
912    }
913
914    #[test]
915    fn install_local_endf_rejects_isotope_mismatch() {
916        let cache = tempfile::tempdir().expect("tempdir");
917        let src = tempfile::NamedTempFile::new().expect("src file");
918        std::fs::write(src.path(), W184_FIXTURE).unwrap();
919
920        let retriever = EndfRetriever::with_cache_dir(cache.path());
921        let hf180 = Isotope::new(72, 180).unwrap();
922        let err = retriever
923            .install_local_endf(&hf180, EndfLibrary::EndfB8_0, src.path())
924            .expect_err("must reject ZA mismatch");
925
926        match err {
927            EndfRetrievalError::IsotopeMismatch { expected, found } => {
928                assert!(expected.contains("Hf"), "expected label, got {expected}");
929                assert!(found.contains("W"), "found label, got {found}");
930            }
931            other => panic!("expected IsotopeMismatch, got {other:?}"),
932        }
933        assert!(
934            !retriever
935                .cache_file_path(&hf180, EndfLibrary::EndfB8_0)
936                .exists(),
937            "no cache file must be written on mismatch"
938        );
939    }
940}