1use std::fs::File;
4use std::io::{BufRead, BufReader, Read};
5use std::path::{Path, PathBuf};
6
7use flate2::read::MultiGzDecoder;
8use serde_json::Value;
9use walkdir::WalkDir;
10
11use crate::config::IngestConfig;
12use crate::error::{BbpeError, Result};
13
14#[derive(Debug, Clone)]
16pub struct JsonlSpec {
17 pub path: PathBuf,
19 pub field_path: Vec<String>,
21}
22
23impl std::str::FromStr for JsonlSpec {
24 type Err = String;
25
26 fn from_str(spec: &str) -> std::result::Result<Self, Self::Err> {
27 let mut parts = spec.rsplitn(2, ':');
28 let field = parts
29 .next()
30 .ok_or_else(|| "jsonl specification must include `PATH:FIELD`".to_string())?;
31 let path = parts
32 .next()
33 .ok_or_else(|| "jsonl specification missing file path".to_string())?;
34 if path.is_empty() {
35 return Err("jsonl file path cannot be empty".into());
36 }
37 if field.is_empty() {
38 return Err("jsonl field path cannot be empty".into());
39 }
40 let field_path: Vec<String> = field
41 .split('.')
42 .map(str::trim)
43 .filter(|segment| !segment.is_empty())
44 .map(|segment| segment.to_string())
45 .collect();
46 if field_path.is_empty() {
47 return Err("jsonl field path must contain at least one segment".into());
48 }
49 Ok(Self {
50 path: PathBuf::from(path),
51 field_path,
52 })
53 }
54}
55
56pub struct BinaryChunkStream {
58 files: Vec<PathBuf>,
59 cfg: IngestConfig,
60 file_index: usize,
61 current_file: Option<File>,
62 current_path: Option<PathBuf>,
63 total_chunks: usize,
64 total_bytes: usize,
65}
66
67pub fn collect_paths<P: AsRef<Path>>(inputs: &[P], cfg: &IngestConfig) -> Result<Vec<PathBuf>> {
73 let mut files = Vec::new();
74 for input in inputs {
75 let path = input.as_ref();
76 if !path.exists() {
77 return Err(BbpeError::InvalidConfig(format!(
78 "input path {path:?} does not exist"
79 )));
80 }
81 let metadata = path
82 .symlink_metadata()
83 .map_err(|err| BbpeError::io(err, Some(path.to_path_buf())))?;
84 if metadata.is_dir() {
85 if cfg.recursive {
86 let walker = WalkDir::new(path).follow_links(cfg.follow_symlinks);
87 for entry in walker {
88 let entry = entry.map_err(|err| BbpeError::Internal(err.to_string()))?;
89 if entry.file_type().is_file() {
90 files.push(entry.path().to_path_buf());
91 }
92 }
93 } else {
94 for entry in std::fs::read_dir(path)
95 .map_err(|err| BbpeError::io(err, Some(path.to_path_buf())))?
96 {
97 let entry =
98 entry.map_err(|err| BbpeError::io(err, Some(path.to_path_buf())))?;
99 let entry_path = entry.path();
100 if entry_path.is_file() {
101 files.push(entry_path);
102 }
103 }
104 }
105 } else if metadata.is_file() {
106 files.push(path.to_path_buf());
107 }
108 }
109 if files.is_empty() {
110 return Err(BbpeError::InvalidConfig(
111 "no files discovered in provided inputs".into(),
112 ));
113 }
114 Ok(files)
115}
116
117pub fn load_binary_corpus<P: AsRef<Path>>(
122 inputs: &[P],
123 cfg: &IngestConfig,
124) -> Result<Vec<Vec<u8>>> {
125 let file_paths = collect_paths(inputs, cfg)?;
126 let mut sequences = Vec::new();
127 for file_path in file_paths {
128 let mut file =
129 File::open(&file_path).map_err(|err| BbpeError::io(err, Some(file_path.clone())))?;
130 if cfg.chunk_size == 0 {
131 let mut buffer = Vec::new();
132 file.read_to_end(&mut buffer)
133 .map_err(|err| BbpeError::io(err, Some(file_path.clone())))?;
134 if !buffer.is_empty() {
135 sequences.push(buffer);
136 }
137 continue;
138 }
139
140 loop {
141 let mut buffer = vec![0u8; cfg.chunk_size];
142 let read = file
143 .read(&mut buffer)
144 .map_err(|err| BbpeError::io(err, Some(file_path.clone())))?;
145 if read == 0 {
146 break;
147 }
148 buffer.truncate(read);
149 sequences.push(buffer);
150 }
151 }
152 if sequences.is_empty() {
153 return Err(BbpeError::InvalidConfig(
154 "no binary data could be loaded from inputs".into(),
155 ));
156 }
157 Ok(sequences)
158}
159
160pub fn load_jsonl_corpus(specs: &[JsonlSpec]) -> Result<Vec<Vec<u8>>> {
162 if specs.is_empty() {
163 return Ok(Vec::new());
164 }
165
166 let sequences: Result<Vec<Vec<u8>>> = stream_jsonl_corpus(specs)?.collect();
167 let sequences = sequences?;
168 if sequences.is_empty() {
169 return Err(BbpeError::InvalidConfig(
170 "no sequences extracted from JSONL inputs".into(),
171 ));
172 }
173 Ok(sequences)
174}
175
176fn open_jsonl_reader(path: &Path) -> Result<Box<dyn BufRead + Send>> {
177 let file = File::open(path).map_err(|err| BbpeError::io(err, Some(path.to_path_buf())))?;
178 if is_gzip_path(path) {
179 let decoder = MultiGzDecoder::new(file);
180 Ok(Box::new(BufReader::new(decoder)))
181 } else {
182 Ok(Box::new(BufReader::new(file)))
183 }
184}
185
186fn is_gzip_path(path: &Path) -> bool {
187 path.extension()
188 .and_then(|ext| ext.to_str())
189 .map(|ext| ext.eq_ignore_ascii_case("gz"))
190 .unwrap_or(false)
191}
192
193pub fn stream_binary_corpus<P: AsRef<Path>>(
200 inputs: &[P],
201 cfg: &IngestConfig,
202) -> Result<BinaryChunkStream> {
203 let files = collect_paths(inputs, cfg)?;
204 let mut total_chunks = 0usize;
205 let mut total_bytes = 0usize;
206 let chunk_size = cfg.chunk_size;
207
208 for path in &files {
209 let metadata = path
210 .metadata()
211 .map_err(|err| BbpeError::io(err, Some(path.clone())))?;
212 let file_len = metadata.len();
213 let file_len_usize = usize::try_from(file_len).map_err(|_| {
214 BbpeError::InvalidConfig(format!(
215 "input file {} exceeds usize::MAX ({} bytes)",
216 path.display(),
217 file_len
218 ))
219 })?;
220 total_bytes = total_bytes.saturating_add(file_len_usize);
221
222 if file_len == 0 {
223 continue;
224 }
225
226 if chunk_size == 0 {
227 total_chunks = total_chunks.saturating_add(1);
228 } else {
229 let size = u64::try_from(chunk_size).map_err(|_| {
230 BbpeError::InvalidConfig(format!("chunk size {chunk_size} exceeds u64::MAX"))
231 })?;
232 let mut chunks = file_len / size;
233 if file_len % size != 0 {
234 chunks = chunks.saturating_add(1);
235 }
236 let chunks_usize = usize::try_from(chunks).map_err(|_| {
237 BbpeError::InvalidConfig(format!(
238 "file {} produces more chunks than usize::MAX allows",
239 path.display()
240 ))
241 })?;
242 total_chunks = total_chunks.saturating_add(chunks_usize);
243 }
244 }
245
246 if total_chunks == 0 {
247 return Err(BbpeError::InvalidConfig(
248 "no binary data could be loaded from inputs".into(),
249 ));
250 }
251
252 Ok(BinaryChunkStream {
253 files,
254 cfg: cfg.clone(),
255 file_index: 0,
256 current_file: None,
257 current_path: None,
258 total_chunks,
259 total_bytes,
260 })
261}
262
263impl BinaryChunkStream {
264 pub fn total_chunks(&self) -> usize {
266 self.total_chunks
267 }
268
269 pub fn total_bytes(&self) -> usize {
271 self.total_bytes
272 }
273}
274
275impl Iterator for BinaryChunkStream {
276 type Item = Result<Vec<u8>>;
277
278 fn next(&mut self) -> Option<Self::Item> {
279 loop {
280 if self.file_index >= self.files.len() {
281 return None;
282 }
283
284 if self.current_file.is_none() {
285 let path = self.files[self.file_index].clone();
286 match File::open(&path) {
287 Ok(file) => {
288 self.current_path = Some(path);
289 self.current_file = Some(file);
290 }
291 Err(err) => {
292 self.file_index = self.file_index.saturating_add(1);
293 return Some(Err(BbpeError::io(err, Some(path))));
294 }
295 }
296 }
297
298 let chunk_size = self.cfg.chunk_size;
299 if chunk_size == 0 {
300 let mut file = self
301 .current_file
302 .take()
303 .expect("current file must exist when chunk_size == 0");
304 let path = self
305 .current_path
306 .take()
307 .unwrap_or_else(|| self.files[self.file_index].clone());
308 let mut buffer = Vec::new();
309 let result = file.read_to_end(&mut buffer);
310 self.file_index = self.file_index.saturating_add(1);
311 match result {
312 Ok(_) => {
313 if buffer.is_empty() {
314 continue;
315 }
316 return Some(Ok(buffer));
317 }
318 Err(err) => return Some(Err(BbpeError::io(err, Some(path)))),
319 }
320 } else {
321 let file = self
322 .current_file
323 .as_mut()
324 .expect("current file must exist when chunk_size > 0");
325 let path = self
326 .current_path
327 .as_ref()
328 .cloned()
329 .unwrap_or_else(|| self.files[self.file_index].clone());
330
331 let mut buffer = vec![0u8; chunk_size];
332 match file.read(&mut buffer) {
333 Ok(0) => {
334 self.current_file = None;
335 self.current_path = None;
336 self.file_index = self.file_index.saturating_add(1);
337 continue;
338 }
339 Ok(read) => {
340 buffer.truncate(read);
341 if buffer.is_empty() {
342 continue;
343 }
344 return Some(Ok(buffer));
345 }
346 Err(err) => {
347 self.current_file = None;
348 self.current_path = None;
349 self.file_index = self.file_index.saturating_add(1);
350 return Some(Err(BbpeError::io(err, Some(path))));
351 }
352 }
353 }
354 }
355 }
356}
357
358pub fn stream_jsonl_corpus(specs: &[JsonlSpec]) -> Result<JsonlStream> {
360 Ok(JsonlStream::new(specs.to_vec()))
361}
362
363pub struct JsonlStream {
365 specs: Vec<JsonlSpec>,
366 current_index: usize,
367 reader: Option<Box<dyn BufRead + Send>>,
368 buffer: String,
369 line_index: usize,
370}
371
372impl JsonlStream {
373 fn new(specs: Vec<JsonlSpec>) -> Self {
374 Self {
375 specs,
376 current_index: 0,
377 reader: None,
378 buffer: String::new(),
379 line_index: 0,
380 }
381 }
382}
383
384impl Iterator for JsonlStream {
385 type Item = Result<Vec<u8>>;
386
387 fn next(&mut self) -> Option<Self::Item> {
388 loop {
389 if self.current_index >= self.specs.len() {
390 return None;
391 }
392
393 if self.reader.is_none() {
394 let spec = &self.specs[self.current_index];
395 match open_jsonl_reader(&spec.path) {
396 Ok(reader) => {
397 self.reader = Some(reader);
398 self.line_index = 0;
399 }
400 Err(err) => {
401 self.current_index = self.current_index.saturating_add(1);
402 return Some(Err(err));
403 }
404 }
405 }
406
407 let spec = &self.specs[self.current_index];
408 let reader = self
409 .reader
410 .as_mut()
411 .expect("reader should be initialised before reading");
412 self.buffer.clear();
413 match reader.read_line(&mut self.buffer) {
414 Ok(0) => {
415 self.reader = None;
416 self.line_index = 0;
417 self.current_index = self.current_index.saturating_add(1);
418 continue;
419 }
420 Ok(_) => {
421 self.line_index = self.line_index.saturating_add(1);
422 if self.buffer.trim().is_empty() {
423 continue;
424 }
425 match parse_jsonl_line(&self.buffer, spec, self.line_index) {
426 Ok(Some(bytes)) => return Some(Ok(bytes)),
427 Ok(None) => continue,
428 Err(err) => {
429 self.reader = None;
430 self.current_index = self.current_index.saturating_add(1);
431 return Some(Err(err));
432 }
433 }
434 }
435 Err(err) => {
436 let path = spec.path.clone();
437 self.reader = None;
438 self.current_index = self.current_index.saturating_add(1);
439 return Some(Err(BbpeError::io(err, Some(path))));
440 }
441 }
442 }
443 }
444}
445
446fn parse_jsonl_line(line: &str, spec: &JsonlSpec, line_idx: usize) -> Result<Option<Vec<u8>>> {
447 let value: Value = serde_json::from_str(line).map_err(|err| {
448 BbpeError::InvalidConfig(format!(
449 "failed to parse JSON in {} on line {}: {err}",
450 spec.path.display(),
451 line_idx
452 ))
453 })?;
454 let mut current = &value;
455 for key in &spec.field_path {
456 current = current.get(key).ok_or_else(|| {
457 BbpeError::InvalidConfig(format!(
458 "field `{}` missing in {} on line {}",
459 spec.field_path.join("."),
460 spec.path.display(),
461 line_idx
462 ))
463 })?;
464 }
465 let text = current.as_str().ok_or_else(|| {
466 BbpeError::InvalidConfig(format!(
467 "field `{}` in {} line {} is not a string",
468 spec.field_path.join("."),
469 spec.path.display(),
470 line_idx
471 ))
472 })?;
473 if text.is_empty() {
474 return Ok(None);
475 }
476 Ok(Some(text.as_bytes().to_vec()))
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use std::fs;
483 use tempfile::tempdir;
484
485 #[test]
486 fn collect_paths_discovers_files_recursively() {
487 let dir = tempdir().expect("tempdir");
488 let nested = dir.path().join("nested");
489 fs::create_dir(&nested).expect("create nested directory");
490 let file_a = dir.path().join("a.bin");
491 let file_b = nested.join("b.bin");
492 fs::write(&file_a, [1u8, 2, 3]).expect("write a");
493 fs::write(&file_b, [4u8, 5, 6]).expect("write b");
494
495 let cfg = IngestConfig {
496 recursive: true,
497 ..IngestConfig::default()
498 };
499 let mut paths = collect_paths(&[dir.path()], &cfg).expect("collect paths");
500 paths.sort();
501 assert_eq!(paths, vec![file_a, file_b]);
502 }
503
504 #[test]
505 fn load_binary_corpus_splits_chunks() {
506 let dir = tempdir().expect("tempdir");
507 let file = dir.path().join("data.bin");
508 let bytes: Vec<u8> = (0..=9).collect();
509 fs::write(&file, &bytes).expect("write data");
510
511 let cfg = IngestConfig {
512 chunk_size: 4,
513 ..IngestConfig::default()
514 };
515 let sequences =
516 load_binary_corpus(&[file], &cfg).expect("load corpus with chunking enabled");
517 assert_eq!(sequences.len(), 3);
518 assert_eq!(sequences[0], vec![0, 1, 2, 3]);
519 assert_eq!(sequences[1], vec![4, 5, 6, 7]);
520 assert_eq!(sequences[2], vec![8, 9]);
521 }
522
523 #[test]
524 fn load_binary_corpus_entire_file_when_chunk_zero() {
525 let dir = tempdir().expect("tempdir");
526 let file = dir.path().join("data.bin");
527 let bytes: Vec<u8> = (0..=9).collect();
528 fs::write(&file, &bytes).expect("write data");
529
530 let cfg = IngestConfig {
531 chunk_size: 0,
532 ..IngestConfig::default()
533 };
534 let sequences = load_binary_corpus(&[file], &cfg).expect("load corpus without chunking");
535 assert_eq!(sequences, vec![bytes]);
536 }
537
538 #[test]
539 fn stream_binary_corpus_matches_loaded_sequences() {
540 let dir = tempdir().expect("tempdir");
541 let file = dir.path().join("data.bin");
542 let bytes: Vec<u8> = (0..=15).collect();
543 fs::write(&file, &bytes).expect("write data");
544
545 let cfg = IngestConfig {
546 chunk_size: 4,
547 ..IngestConfig::default()
548 };
549 let expected = load_binary_corpus(std::slice::from_ref(&file), &cfg)
550 .expect("load corpus with chunking enabled");
551 let stream =
552 stream_binary_corpus(&[file], &cfg).expect("stream corpus with chunking enabled");
553 assert_eq!(stream.total_chunks(), expected.len());
554 assert_eq!(
555 stream.total_bytes(),
556 expected.iter().map(|chunk| chunk.len()).sum::<usize>()
557 );
558 let streamed = stream
559 .map(|item| item.expect("stream item"))
560 .collect::<Vec<_>>();
561 assert_eq!(streamed, expected);
562 }
563
564 #[test]
565 fn stream_jsonl_corpus_matches_loader() {
566 let dir = tempdir().expect("tempdir");
567 let file = dir.path().join("data.jsonl");
568 let contents = r#"
569{"text":"alpha"}
570{"text":""}
571{"text":"beta gamma"}
572"#;
573 fs::write(&file, contents.trim()).expect("write jsonl");
574 let spec = JsonlSpec {
575 path: file.clone(),
576 field_path: vec!["text".to_string()],
577 };
578
579 let expected =
580 load_jsonl_corpus(std::slice::from_ref(&spec)).expect("load jsonl corpus eagerly");
581 assert_eq!(expected.len(), 2);
582
583 let streamed: Result<Vec<_>> = stream_jsonl_corpus(std::slice::from_ref(&spec))
584 .expect("build stream")
585 .collect();
586 assert_eq!(streamed.expect("collect stream"), expected);
587 }
588}