1use crate::e2s::{error::E2sError, types::Version};
4use std::{
5 fs::File,
6 io::{self, Read, Seek, Write},
7 path::Path,
8};
9
10pub trait EraFileFormat: Sized {
12 type EraGroup;
14
15 type Id: EraFileId;
17
18 fn version(&self) -> &Version;
20
21 fn group(&self) -> &Self::EraGroup;
23
24 fn id(&self) -> &Self::Id;
26
27 fn new(group: Self::EraGroup, id: Self::Id) -> Self;
29}
30
31pub trait EraFileId: Clone {
33 const FILE_TYPE: EraFileType;
35
36 const ITEMS_PER_ERA: u64;
38
39 fn network_name(&self) -> &str;
41
42 fn start_number(&self) -> u64;
44
45 fn count(&self) -> u32;
47
48 fn hash(&self) -> Option<[u8; 4]>;
50
51 fn include_era_count(&self) -> bool;
53
54 fn era_number(&self) -> u64 {
56 self.start_number() / Self::ITEMS_PER_ERA
57 }
58
59 fn era_count(&self) -> u64 {
65 if self.count() == 0 {
66 return 0;
67 }
68 let first_era = self.era_number();
69 let last_number = self.start_number() + self.count() as u64 - 1;
70 let last_era = last_number / Self::ITEMS_PER_ERA;
71 last_era - first_era + 1
72 }
73
74 fn to_file_name(&self) -> String {
76 Self::FILE_TYPE.format_filename(
77 self.network_name(),
78 self.era_number(),
79 self.hash(),
80 self.include_era_count(),
81 self.era_count(),
82 )
83 }
84}
85
86pub trait StreamReader<R: Read + Seek>: Sized {
88 type File: EraFileFormat;
90
91 type Iterator;
93
94 fn new(reader: R) -> Self;
96
97 fn read(self, network_name: String) -> Result<Self::File, E2sError>;
99
100 fn iter(self) -> Self::Iterator;
102}
103
104pub trait FileReader: StreamReader<File> {
106 fn open<P: AsRef<Path>>(
108 path: P,
109 network_name: impl Into<String>,
110 ) -> Result<Self::File, E2sError> {
111 let file = File::open(path).map_err(E2sError::Io)?;
112 let reader = Self::new(file);
113 reader.read(network_name.into())
114 }
115}
116
117impl<T: StreamReader<File>> FileReader for T {}
118
119pub trait StreamWriter<W: Write>: Sized {
121 type File: EraFileFormat;
123
124 fn new(writer: W) -> Self;
126
127 fn write_version(&mut self) -> Result<(), E2sError>;
129
130 fn write_file(&mut self, file: &Self::File) -> Result<(), E2sError>;
132
133 fn flush(&mut self) -> Result<(), E2sError>;
135}
136
137pub trait FileWriter {
139 type File: EraFileFormat<Id: EraFileId>;
141
142 fn create<P: AsRef<Path>>(path: P, file: &Self::File) -> Result<(), E2sError>;
144
145 fn create_with_id<P: AsRef<Path>>(directory: P, file: &Self::File) -> Result<(), E2sError>;
147}
148
149impl<T: StreamWriter<File>> FileWriter for T {
150 type File = T::File;
151
152 fn create<P: AsRef<Path>>(path: P, file: &Self::File) -> Result<(), E2sError> {
154 let file_handle = File::create(path).map_err(E2sError::Io)?;
155 let mut writer = Self::new(file_handle);
156 writer.write_file(file)?;
157 Ok(())
158 }
159
160 fn create_with_id<P: AsRef<Path>>(directory: P, file: &Self::File) -> Result<(), E2sError> {
162 let filename = file.id().to_file_name();
163 let path = directory.as_ref().join(filename);
164 Self::create(path, file)
165 }
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
170pub enum EraFileType {
171 Era,
174 Era1,
177 Ere,
180}
181
182impl EraFileType {
183 const ALL: [Self; 3] = [Self::Era, Self::Era1, Self::Ere];
186
187 pub const fn extension(&self) -> &'static str {
192 match self {
193 Self::Era => ".era",
194 Self::Era1 => ".era1",
195 Self::Ere => ".ere",
196 }
197 }
198
199 pub const fn extensions(&self) -> &'static [&'static str] {
204 match self {
205 Self::Era => &[".era"],
206 Self::Era1 => &[".era1"],
207 Self::Ere => &[".erae", ".ere"],
208 }
209 }
210
211 pub const fn has_checksums(&self) -> bool {
215 matches!(self, Self::Era1 | Self::Ere)
216 }
217
218 pub fn from_filename(filename: &str) -> Option<Self> {
220 Self::ALL.into_iter().find(|ty| ty.extensions().iter().any(|ext| filename.ends_with(ext)))
221 }
222
223 pub fn format_filename(
231 &self,
232 network_name: &str,
233 era_number: u64,
234 hash: Option<[u8; 4]>,
235 include_era_count: bool,
236 era_count: u64,
237 ) -> String {
238 let hash = format_hash(hash);
239 let era_count = if include_era_count { format!("-{era_count:05}") } else { String::new() };
241 format!("{network_name}-{era_number:05}{era_count}-{hash}{}", self.extension())
242 }
243
244 pub fn from_dir(dir: impl AsRef<Path>) -> io::Result<Option<Self>> {
249 let mut found: Option<Self> = None;
250 for entry in std::fs::read_dir(dir)? {
251 if let Some(name) = entry?.file_name().to_str() &&
252 let Some(era_type) = Self::from_filename(name)
253 {
254 match found {
255 Some(existing) if existing != era_type => {
256 return Err(io::Error::new(
257 io::ErrorKind::InvalidInput,
258 format!(
259 "directory mixes ERA formats ({} and {}); import one at a time",
260 existing.extension(),
261 era_type.extension(),
262 ),
263 ));
264 }
265 _ => found = Some(era_type),
266 }
267 }
268 }
269
270 Ok(found)
271 }
272
273 pub fn from_url(url: &str) -> Self {
278 let file_url = url.split(['?', '#']).next().unwrap_or(url);
279 if let Some(ty) = Self::from_filename(file_url) {
280 return ty;
281 }
282 if url.contains("era1") {
283 Self::Era1
284 } else if url.contains("erae") {
285 Self::Ere
286 } else {
287 Self::Era
288 }
289 }
290}
291
292pub fn format_hash(hash: Option<[u8; 4]>) -> String {
294 match hash {
295 Some(h) => format!("{:02x}{:02x}{:02x}{:02x}", h[0], h[1], h[2], h[3]),
296 None => "00000000".to_string(),
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn test_from_url_detection() {
306 assert_eq!(
308 EraFileType::from_url("https://host/mainnet-00000-abcd1234.ere"),
309 EraFileType::Ere
310 );
311 assert_eq!(
312 EraFileType::from_url("https://host/mainnet-00000-abcd1234.era1"),
313 EraFileType::Era1
314 );
315 assert_eq!(
316 EraFileType::from_url("https://host/mainnet-00000-abcd1234.era"),
317 EraFileType::Era
318 );
319
320 assert_eq!(
322 EraFileType::from_url("https://host/era1/mainnet-00000-abcd1234.ere"),
323 EraFileType::Ere
324 );
325
326 assert_eq!(EraFileType::from_url("https://mainnet.era1.nimbus.team/"), EraFileType::Era1);
329 assert_eq!(EraFileType::from_url("https://era.ithaca.xyz/"), EraFileType::Era);
330 assert_eq!(
331 EraFileType::from_url("https://data.ethpandaops.io/erae/mainnet/"),
332 EraFileType::Ere
333 );
334 }
335
336 #[test]
337 fn test_from_filename_detection() {
338 assert_eq!(
339 EraFileType::from_filename("mainnet-00000-abcd1234.era"),
340 Some(EraFileType::Era)
341 );
342 assert_eq!(
343 EraFileType::from_filename("mainnet-00000-abcd1234.era1"),
344 Some(EraFileType::Era1)
345 );
346 assert_eq!(
347 EraFileType::from_filename("mainnet-00000-abcd1234.ere"),
348 Some(EraFileType::Ere)
349 );
350 assert_eq!(
352 EraFileType::from_filename("mainnet-00000-abcd1234.erae"),
353 Some(EraFileType::Ere)
354 );
355 assert_eq!(
357 EraFileType::from_filename("mainnet-00000-abcd1234-noproofs.ere"),
358 Some(EraFileType::Ere)
359 );
360 assert_eq!(
361 EraFileType::from_filename("mainnet-00000-abcd1234-noproofs.erae"),
362 Some(EraFileType::Ere)
363 );
364 assert_eq!(EraFileType::from_filename("mainnet-00000-abcd1234.txt"), None);
365 }
366
367 #[test]
368 fn from_dir_detects_single_format() {
369 let dir = tempfile::tempdir().unwrap();
370 std::fs::write(dir.path().join("mainnet-00000-aaaaaaaa.era1"), b"x").unwrap();
371 std::fs::write(dir.path().join("mainnet-00001-bbbbbbbb.era1"), b"x").unwrap();
372
373 assert_eq!(EraFileType::from_dir(dir.path()).unwrap(), Some(EraFileType::Era1));
374 }
375
376 #[test]
377 fn from_dir_errors_on_mixed_formats() {
378 let dir = tempfile::tempdir().unwrap();
379 std::fs::write(dir.path().join("mainnet-00000-aaaaaaaa.era1"), b"x").unwrap();
380 std::fs::write(dir.path().join("mainnet-00000-bbbbbbbb.era"), b"x").unwrap();
381
382 assert!(EraFileType::from_dir(dir.path()).is_err());
383 }
384}