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)]
35pub enum EndfLibrary {
36 EndfB8_0,
38 EndfB8_1,
40 Jeff3_3,
42 Jendl5,
44 Tendl2023,
47 Cendl3_2,
52}
53
54impl EndfLibrary {
55 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 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 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
101pub fn default_cache_dir(library: EndfLibrary) -> PathBuf {
109 default_cache_root().join(library.cache_dir_name())
110}
111
112pub 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
126pub struct EndfRetriever {
128 cache_root: PathBuf,
130 base_url: String,
133 client: reqwest::blocking::Client,
136}
137
138impl EndfRetriever {
139 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 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 pub fn cache_dir(&self, library: EndfLibrary) -> PathBuf {
162 self.cache_root.join(library.cache_dir_name())
163 }
164
165 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 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 if cache_path.exists() {
193 let contents = fs::read_to_string(&cache_path)?;
194 return Ok((cache_path, contents));
195 }
196
197 let contents = self.download_endf(isotope, library, mat)?;
199
200 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 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 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 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 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
362pub 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
383pub 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
398pub 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#[derive(Debug, thiserror::Error)]
413pub enum EndfRetrievalError {
414 #[error("Network error: {0}")]
416 NetworkError(String),
417
418 #[error("Remote access blocked: HTTP {status} for {url}. {message}")]
420 RemoteAccessBlocked {
421 status: u16,
422 url: String,
423 message: String,
424 },
425
426 #[error("{isotope} is not available in the {library} library")]
428 NotInLibrary { isotope: String, library: String },
429
430 #[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 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
505fn 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
545fn 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
559fn 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
632fn 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 #[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 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 " 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 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 #[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 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 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 #[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"); write_zip_with_endf(&zip_path, "inner.endf", W184_FIXTURE).expect("write zip");
834
835 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 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}