1use 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";
20const 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35pub enum EndfLibrary {
36 #[serde(rename = "ENDF/B-VIII.0")]
38 EndfB8_0,
39 #[serde(rename = "ENDF/B-VIII.1")]
41 EndfB8_1,
42 #[serde(rename = "JEFF-3.3")]
44 Jeff3_3,
45 #[serde(rename = "JENDL-5")]
47 Jendl5,
48 #[serde(rename = "TENDL-2023")]
51 Tendl2023,
52 #[serde(rename = "CENDL-3.2")]
57 Cendl3_2,
58}
59
60impl EndfLibrary {
61 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 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 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 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
119pub fn default_cache_dir(library: EndfLibrary) -> PathBuf {
127 default_cache_root().join(library.cache_dir_name())
128}
129
130pub 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
144pub struct EndfRetriever {
146 cache_root: PathBuf,
148 base_url: String,
151 client: reqwest::blocking::Client,
154}
155
156impl EndfRetriever {
157 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 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 pub fn cache_dir(&self, library: EndfLibrary) -> PathBuf {
180 self.cache_root.join(library.cache_dir_name())
181 }
182
183 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 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 if cache_path.exists() {
211 let contents = fs::read_to_string(&cache_path)?;
212 return Ok((cache_path, contents));
213 }
214
215 let contents = self.download_endf(isotope, library, mat)?;
217
218 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 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 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 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 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
380pub 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
401pub 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
416pub 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#[derive(Debug, thiserror::Error)]
431pub enum EndfRetrievalError {
432 #[error("Network error: {0}")]
434 NetworkError(String),
435
436 #[error("Remote access blocked: HTTP {status} for {url}. {message}")]
438 RemoteAccessBlocked {
439 status: u16,
440 url: String,
441 message: String,
442 },
443
444 #[error("{isotope} is not available in the {library} library")]
446 NotInLibrary { isotope: String, library: String },
447
448 #[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 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
534fn 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
577fn 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
591fn 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
664fn 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 #[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 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 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 #[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 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 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 #[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"); write_zip_with_endf(&zip_path, "inner.endf", W184_FIXTURE).expect("write zip");
893
894 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 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}