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