1mod backseeking;
21mod buffered;
22mod chunked; mod stdin;
24pub mod strategies; use std::io::{self, SeekFrom, Write};
27use std::path::{Path, PathBuf};
28
29pub use backseeking::*;
30pub use buffered::*;
31use bytes::BytesMut;
32pub use chunked::*;
33use miette::{IntoDiagnostic, Result};
34use owo_colors::OwoColorize;
35pub use stdin::*;
36pub use strategies::*;
37
38use crate::defaults::io::*;
39use crate::defaults::processing::*;
40use crate::errors::{FileError, TaleError, find_similar_files};
41use crate::multiplexed::watcher::{MultiFileWatcher, WatchEvent, WatcherConfig};
42use crate::{config, process_line};
43
44async fn wait_for_file_creation(target_path: &Path) -> Result<()> {
46 use std::time::Duration;
47
48 eprintln!("Watching for '{}'…", target_path.display().yellow().bold());
49
50 let parent_dir = target_path.parent().ok_or_else(|| {
52 TaleError::from(Box::new(FileError::NotFound {
53 path: target_path.to_path_buf(),
54 similar_files: vec!["Parent directory not found".to_string()],
55 }))
56 })?;
57
58 if !parent_dir.exists() {
60 return Err(TaleError::from(Box::new(FileError::NotFound {
61 path: parent_dir.to_path_buf(),
62 similar_files: vec!["Parent directory must exist for file watching".to_string()],
63 }))
64 .into());
65 }
66
67 let target_filename = target_path.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
68 miette::Report::from(TaleError::from(Box::new(FileError::NotFound {
69 path: target_path.to_path_buf(),
70 similar_files: vec!["Invalid filename".to_string()],
71 })))
72 })?;
73
74 let mut watcher = MultiFileWatcher::new(WatcherConfig::default());
76
77 watcher.add_files(vec![parent_dir]).await?;
79
80 let mut event_receiver = watcher.watch().await?;
82
83 let mut elapsed_seconds = 0;
84 let mut last_message_time = std::time::Instant::now();
85
86 loop {
87 match tokio::time::timeout(Duration::from_secs(5), event_receiver.recv()).await {
89 Ok(Some(event)) => {
90 match event {
91 WatchEvent::FileCreated(created_path) => {
92 if let Some(created_filename) = created_path.file_name().and_then(|n| n.to_str())
93 && created_filename == target_filename
94 {
95 eprintln!("+++> '{}' created; tailing", target_filename.yellow().bold());
96 return Ok(());
97 }
98 }
99 WatchEvent::Error(_err) => {
100 }
102 _ => {
103 }
105 }
106 }
107 Ok(None) => {
108 return Err(TaleError::from(Box::new(FileError::NotFound {
110 path: target_path.to_path_buf(),
111 similar_files: vec!["File watcher stopped unexpectedly".to_string()],
112 }))
113 .into());
114 }
115 Err(_) => {
116 elapsed_seconds += 5;
118 if last_message_time.elapsed() >= Duration::from_secs(30) {
119 eprintln!(
120 "Still watching for '{}' ({}s elapsed)...",
121 target_path.display().yellow().bold(),
122 elapsed_seconds.bright_magenta()
123 );
124 last_message_time = std::time::Instant::now();
125 }
126
127 if target_path.exists() {
129 eprintln!("+++> '{}' created; tailing", target_filename.yellow().bold());
130 return Ok(());
131 }
132 }
133 }
134 }
135}
136
137pub async fn handle_file(fpath: &Path) -> Result<()> {
139 let sticky = config::sticky();
140
141 if !fpath.exists() {
143 if sticky {
144 wait_for_file_creation(fpath).await?;
146
147 if !fpath.is_file() {
149 return Err(TaleError::from(Box::new(FileError::not_a_file_with_type(fpath.to_path_buf()))).into());
150 }
151 } else {
152 let similar_files = find_similar_files(fpath);
154 return Err(TaleError::from(Box::new(FileError::not_found_with_suggestions(
155 fpath.to_path_buf(),
156 similar_files,
157 )))
158 .into());
159 }
160 }
161
162 if !fpath.is_file() {
164 return Err(TaleError::from(Box::new(FileError::not_a_file_with_type(fpath.to_path_buf()))).into());
165 }
166
167 let mut processor = create_file_processor(fpath, None).map_err(|e| enhance_error_context(e, fpath))?;
169
170 if let FileProcessorType::BackSeeking(mut backseeker) = processor {
173 return backseeker.tail();
174 }
175
176 let offset = config::offset();
178 let offset_unit = config::offset_unit();
179
180 match (offset.is_positive(), offset_unit) {
182 (true, config::OffsetUnit::Lines) if offset > 0 => {
184 processor.skip_lines(offset as u64)?;
185
186 let mut buffer = BytesMut::with_capacity(OUTPUT_BUFFER_CAPACITY);
188 let mut outlock = io::stdout().lock();
189
190 processor.process_lines(|line| {
191 process_line(line, &mut buffer, &mut outlock)
192 .map_err(|e| TaleError::from(std::io::Error::other(e.to_string())))
193 })?;
194
195 outlock.flush().into_diagnostic()?;
196 }
197
198 _ => {
200 let mut buffer = BytesMut::with_capacity(OUTPUT_BUFFER_CAPACITY);
201 let mut outlock = io::stdout().lock();
202
203 processor.process_lines(|line| {
204 process_line(line, &mut buffer, &mut outlock)
205 .map_err(|e| TaleError::from(std::io::Error::other(e.to_string())))
206 })?;
207
208 outlock.flush().into_diagnostic()?;
209 }
210 }
211 Ok(())
212}
213
214fn enhance_error_context(error: TaleError, path: &Path) -> TaleError {
215 match error {
216 TaleError::Io(io_error) => {
217 let crate::errors::IoError::OperationFailed { source, .. } = io_error.as_ref();
218 if source.kind() == std::io::ErrorKind::PermissionDenied {
219 let suggestion = if cfg!(unix) {
220 Some(format!("Try: chmod +r {}", path.display()))
221 } else {
222 Some("Check file permissions in Properties".to_string())
223 };
224 Box::new(FileError::permission_denied_with_suggestion(
225 path.to_path_buf(),
226 suggestion,
227 ))
228 .into()
229 } else {
230 TaleError::Io(io_error)
231 }
232 }
233 other => other,
234 }
235}
236
237pub fn create_file_processor<P: AsRef<Path>>(
239 path: P,
240 file_size_hint: Option<u64>,
241) -> Result<FileProcessorType<'static>, TaleError> {
242 let path = path.as_ref();
243 let _strategy = if cfg!(debug_assertions) && config::conservative() {
244 Strategy::Static(StaticStrategy::conservative())
245 } else {
246 Strategy::default()
248 };
249
250 let file_size = file_size_hint.unwrap_or_else(|| std::fs::metadata(path).map(|m| m.len()).unwrap_or(0));
252
253 let offset = config::offset();
254 let offset_unit = config::offset_unit();
255 let large_offset = offset.abs() > LARGE_OFFSET_THRESHOLD as i64;
256
257 let use_chunked = !config::disable_chunked()
259 && (config::force_chunked()
260 || (file_size > CHUNKED_WITH_OFFSET_FILE_SIZE && large_offset)
261 || file_size > ALWAYS_CHUNKED_FILE_SIZE);
262
263 if offset < 0 || matches!(offset_unit, config::OffsetUnit::Bytes | config::OffsetUnit::Blocks) {
267 let processor = BackSeekingProcessor::new(PathBuf::from(path));
268 return Ok(FileProcessorType::BackSeeking(processor));
269 }
270
271 if use_chunked {
272 let reader = ChunkedFileReader::with_optimal_config(path)?;
273 return Ok(FileProcessorType::Chunked(Box::new(reader)));
274 }
275
276 let reader = BufferedFileProcessor::new(path)?;
278 Ok(FileProcessorType::Buffered(reader))
279}
280
281pub trait FileProcessor {
283 fn process_lines<F>(&mut self, line_processor: F) -> Result<(), TaleError>
285 where
286 F: FnMut(&str) -> Result<(), TaleError>;
287
288 fn skip_lines(&mut self, count: u64) -> Result<(), TaleError>;
290
291 fn file_size(&self) -> u64;
293
294 fn seek(&mut self, pos: SeekFrom) -> Result<u64, TaleError>;
296
297 fn position(&self) -> u64;
299}
300
301pub enum FileProcessorType<'a> {
303 Buffered(BufferedFileProcessor),
304 Chunked(Box<ChunkedFileReader>),
305 BackSeeking(BackSeekingProcessor<'a>),
306}
307
308impl<'a> FileProcessor for FileProcessorType<'a> {
309 fn process_lines<F>(&mut self, line_processor: F) -> Result<(), TaleError>
310 where
311 F: FnMut(&str) -> Result<(), TaleError>,
312 {
313 match self {
314 FileProcessorType::Buffered(processor) => processor.process_lines(line_processor),
315 FileProcessorType::Chunked(processor) => processor.process_lines(line_processor),
316 FileProcessorType::BackSeeking(processor) => processor.process_lines(line_processor),
317 }
318 }
319
320 fn skip_lines(&mut self, count: u64) -> Result<(), TaleError> {
321 match self {
322 FileProcessorType::Buffered(processor) => processor.skip_lines(count),
323 FileProcessorType::Chunked(processor) => processor.skip_lines(count),
324 FileProcessorType::BackSeeking(processor) => processor.skip_lines(count),
325 }
326 }
327
328 fn file_size(&self) -> u64 {
329 match self {
330 FileProcessorType::Buffered(processor) => processor.file_size(),
331 FileProcessorType::Chunked(processor) => processor.file_size(),
332 FileProcessorType::BackSeeking(processor) => processor.file_size(),
333 }
334 }
335
336 fn seek(&mut self, pos: SeekFrom) -> Result<u64, TaleError> {
337 match self {
338 FileProcessorType::Buffered(processor) => processor.seek(pos),
339 FileProcessorType::Chunked(processor) => processor.seek(pos),
340 FileProcessorType::BackSeeking(processor) => processor.seek(pos),
341 }
342 }
343
344 fn position(&self) -> u64 {
345 match self {
346 FileProcessorType::Buffered(processor) => processor.position(),
347 FileProcessorType::Chunked(processor) => processor.position(),
348 FileProcessorType::BackSeeking(processor) => processor.position(),
349 }
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use std::io::Write;
356
357 use tempfile::NamedTempFile;
358
359 use super::*;
360 use crate::config::ConfigOpts;
361 use crate::tests::TestLogPattern;
362
363 fn create_test_file(content: &str) -> NamedTempFile {
364 let mut file = NamedTempFile::new().expect("Failed to create temp file");
365 file.write_all(content.as_bytes()).expect("Failed to write test data");
366 file.flush().expect("Failed to flush test file");
367 file
368 }
369
370 #[test]
371 fn chonk_size_optimizer() {
372 assert_eq!(optimal_chunk_size(500_000, None), 8_192);
374
375 assert_eq!(optimal_chunk_size(50_000_000, None), 131_072); assert_eq!(optimal_chunk_size(500_000_000, None), 524_288); }
385
386 #[test]
387 fn processor_selection() {
388 let testfp = crate::tests::create_test_file(120_000, TestLogPattern::Canonical);
389
390 crate::config::with_config(
392 ConfigOpts {
393 offset: -20,
394 offset_unit: config::OffsetUnit::Lines,
395 force_chunked: false,
396 disable_chunked: false,
397 ..ConfigOpts::default()
398 },
399 || {
400 let result = create_file_processor(&testfp, Some(1_000_000_000))
401 .expect("should create processor for negative offset");
402 assert!(
403 matches!(result, FileProcessorType::BackSeeking(_)),
404 "Negative offset should use Simple processor"
405 );
406 },
407 );
408
409 crate::config::with_config(
411 ConfigOpts {
412 offset: 100,
413 offset_unit: config::OffsetUnit::Bytes,
414 force_chunked: false,
415 disable_chunked: false,
416 ..ConfigOpts::default()
417 },
418 || {
419 let result = create_file_processor(&testfp, Some(1_000_000_000))
420 .expect("should create processor for byte offset");
421 assert!(
422 matches!(result, FileProcessorType::BackSeeking(_)),
423 "Byte offset should use Simple processor"
424 );
425 },
426 );
427
428 crate::config::with_config(
430 ConfigOpts {
431 offset: 100,
432 offset_unit: config::OffsetUnit::Blocks,
433 force_chunked: false,
434 disable_chunked: false,
435 ..ConfigOpts::default()
436 },
437 || {
438 let result = create_file_processor(&testfp, Some(1_000_000_000))
439 .expect("should create processor for block offset");
440 assert!(
441 matches!(result, FileProcessorType::BackSeeking(_)),
442 "Block offset should use Simple processor"
443 );
444 },
445 );
446
447 crate::config::with_config(
449 ConfigOpts {
450 offset: 100,
451 offset_unit: config::OffsetUnit::Lines,
452 force_chunked: true,
453 disable_chunked: false,
454 ..ConfigOpts::default()
455 },
456 || {
457 let result = create_file_processor(&testfp, Some(100_000_000))
458 .expect("should create processor for force_chunked");
459 assert!(
460 matches!(result, FileProcessorType::Chunked(_)),
461 "force_chunked should use Chunked processor"
462 );
463 },
464 );
465
466 crate::config::with_config(
468 ConfigOpts {
469 offset: 20_000, offset_unit: config::OffsetUnit::Lines,
471 force_chunked: false,
472 disable_chunked: true,
473 ..ConfigOpts::default()
474 },
475 || {
476 let result = create_file_processor(&testfp, Some(200_000_000))
477 .expect("should create processor for disable_chunked");
478 assert!(
479 matches!(result, FileProcessorType::Buffered(_)),
480 "disable_chunked should prevent Chunked processor"
481 );
482 },
483 );
484
485 crate::config::with_config(
487 ConfigOpts {
488 offset: 100,
489 offset_unit: config::OffsetUnit::Lines,
490 force_chunked: false,
491 disable_chunked: false,
492 ..ConfigOpts::default()
493 },
494 || {
495 let result = create_file_processor(&testfp, Some(1_500_000_000)) .expect("should create processor for large file");
497 assert!(
498 matches!(result, FileProcessorType::Chunked(_)),
499 "Large file (>1GB) should use Chunked processor"
500 );
501 },
502 );
503
504 crate::config::with_config(
506 ConfigOpts {
507 offset: 20_000, offset_unit: config::OffsetUnit::Lines,
509 force_chunked: false,
510 disable_chunked: false,
511 ..ConfigOpts::default()
512 },
513 || {
514 let result = create_file_processor(&testfp, Some(150_000_000)) .expect("should create processor for large file + large offset");
516 assert!(
517 matches!(result, FileProcessorType::Chunked(_)),
518 "Large file (>100MB) + large offset (>10k) should use Chunked processor"
519 );
520 },
521 );
522
523 crate::config::with_config(
525 ConfigOpts {
526 offset: 100, offset_unit: config::OffsetUnit::Lines,
528 force_chunked: false,
529 disable_chunked: false,
530 ..ConfigOpts::default()
531 },
532 || {
533 let result = create_file_processor(&testfp, Some(10_000_000)) .expect("should create processor for small file + small offset");
535 assert!(
536 matches!(result, FileProcessorType::Buffered(_)),
537 "Small file + small offset should use Buffered processor"
538 );
539 },
540 );
541 }
542
543 #[test]
544 fn can_create_chonker() {
545 let data = b"line1\nline2\nline3\n".to_vec();
546 let chunk = FileChunk::new(data.clone(), 0, data.len() as u64);
547
548 assert_eq!(chunk.size(), data.len());
549 assert!(!chunk.is_empty());
550 assert!(chunk.starts_at_line_boundary);
551 assert!(chunk.ends_at_line_boundary);
552 }
553
554 #[test]
555 fn can_chunkread_small_files() -> Result<(), TaleError> {
556 let test_data = "line1\nline2\nline3\n";
557 let temp_file = create_test_file(test_data);
558
559 let config = ChunkConfig {
560 overlap_size: 2,
561 low_memory_mode: true,
562 };
563 let strategy = StaticStrategy {
564 chunk_size: 8, config: config.clone(),
566 };
567
568 let mut reader = ChunkedFileReader::with_strategy(temp_file.path(), Strategy::Static(strategy))?;
569
570 assert_eq!(reader.file_size(), test_data.len() as u64);
571 assert_eq!(reader.position(), 0);
572 assert!(!reader.is_at_end());
573
574 let mut all_content = String::new();
576 while let Some(chunk) = reader.read_chunk()? {
577 let chunk_str = std::str::from_utf8(&chunk.data).expect("we expected a valid utf8 string in this test");
578 all_content.push_str(chunk_str);
579 }
580
581 assert_eq!(all_content, test_data);
582 assert!(reader.is_at_end());
583
584 Ok(())
585 }
586
587 #[test]
588 fn line_boundary_handling() {
589 let data = b"line1\nline2\npartial".to_vec();
590 let data_len = data.len();
591 let mut chunk = FileChunk::new(data, 0, data_len as u64);
592
593 assert!(!chunk.ends_at_line_boundary);
594
595 let remainder = chunk.split_at_last_line();
596 assert!(remainder.is_some());
597 assert_eq!(
598 remainder.expect("we expected some remainder after the end of the line"),
599 b"partial"
600 );
601 assert_eq!(chunk.data, b"line1\nline2\n");
602 assert!(chunk.ends_at_line_boundary);
603 }
604
605 #[test]
606 fn chunk_iterator_works() {
607 let data = b"line1\nline2\nline3".to_vec();
608 let data_len = data.len();
609 let chunk = FileChunk::new(data, 0, data_len as u64);
610
611 let lines: Vec<&str> = chunk.lines().collect();
612 assert_eq!(lines, vec!["line1", "line2", "line3"]);
613 }
614
615 #[test]
616 fn buffer_thing_works() -> Result<(), TaleError> {
617 let test_data = "line1\nline2\nline3\n";
618 let temp_file = create_test_file(test_data);
619
620 let mut processor = BufferedFileProcessor::new(temp_file.path())?;
621
622 assert_eq!(processor.file_size(), test_data.len() as u64);
623 assert_eq!(processor.position(), 0);
624
625 let mut lines = Vec::new();
626 processor.process_lines(|line| {
627 lines.push(line.to_string());
628 Ok(())
629 })?;
630
631 assert_eq!(lines, vec!["line1", "line2", "line3"]);
632 Ok(())
633 }
634
635 #[test]
636 fn abstract_processor_impl_factory_noun() -> Result<(), TaleError> {
637 let test_data = "line1\nline2\nline3\n";
638 let temp_file = create_test_file(test_data);
639
640 config::with_config(ConfigOpts::default(), || {
642 let mut processor = create_file_processor(temp_file.path(), None).expect("should create processor");
644 assert_eq!(processor.file_size(), test_data.len() as u64);
645
646 let mut lines = Vec::new();
647 processor
648 .process_lines(|line| {
649 lines.push(line.to_string());
650 Ok(())
651 })
652 .expect("should process lines");
653
654 assert_eq!(lines, vec!["line1", "line2", "line3"]);
655 });
656
657 Ok(())
658 }
659
660 #[test]
661 fn can_skip_chunked_lines() -> Result<(), TaleError> {
662 let test_data = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n";
664 let temp_file = create_test_file(test_data);
665
666 let config = ChunkConfig {
668 overlap_size: 2,
669 low_memory_mode: true,
670 };
671 let strategy = StaticStrategy {
672 chunk_size: 15, config: config.clone(),
674 };
675
676 let mut reader = ChunkedFileReader::with_strategy(temp_file.path(), Strategy::Static(strategy))?;
677
678 reader.skip_lines(3)?;
680
681 let mut remaining_lines = Vec::new();
683 reader.process_lines(|line| {
684 remaining_lines.push(line.to_string());
685 Ok(())
686 })?;
687
688 assert_eq!(
690 remaining_lines,
691 vec!["line4", "line5", "line6", "line7", "line8", "line9", "line10"]
692 );
693
694 Ok(())
695 }
696
697 #[test]
698 fn chunked_skip_lines_partial_chunk() -> Result<(), TaleError> {
699 let test_data = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
701 let temp_file = create_test_file(test_data);
702
703 let config = ChunkConfig {
704 overlap_size: 1,
705 low_memory_mode: true,
706 };
707 let strategy = StaticStrategy {
708 chunk_size: 8, config: config.clone(),
710 };
711
712 let mut reader = ChunkedFileReader::with_strategy(temp_file.path(), Strategy::Static(strategy))?;
713
714 reader.skip_lines(5)?;
716
717 let mut next_lines = Vec::new();
719 reader.process_lines(|line| {
720 next_lines.push(line.to_string());
721 if next_lines.len() >= 2 {
722 return Ok(()); }
724 Ok(())
725 })?;
726
727 assert!(next_lines.len() >= 2);
729 assert_eq!(next_lines[0], "f");
730 assert_eq!(next_lines[1], "g");
731
732 Ok(())
733 }
734}