use anyhow::Result;
use clap::Parser;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::SystemTime;
use tracing::info;
mod discovery;
mod sentence_detector;
mod incremental;
mod parallel_processing;
mod restart_log;
use crate::incremental::create_complete_aux_file;
use crate::parallel_processing::FileStats;
use crate::restart_log::{RestartLog, should_process_file};
use futures::stream::StreamExt;
use futures::future::join_all;
use std::sync::Arc;
use std::collections::VecDeque;
#[derive(Serialize, Deserialize, Debug)]
struct RunStats {
run_start: String,
total_processing_time_ms: u64,
total_sentence_detection_time_ms: u64,
total_chars_processed: u64,
total_sentences_detected: u64,
aggregate_sentence_length_stats: Option<crate::parallel_processing::SentenceLengthStats>,
overall_chars_per_sec: f64,
sentence_detection_chars_per_sec: f64,
files_processed: u64,
files_skipped: u64,
files_failed: u64,
file_stats: Vec<FileStats>,
}
async fn process_with_overlapped_pipeline(
root_dir: &std::path::Path,
args: &Args,
restart_log: &mut RestartLog,
) -> Result<(u64, u64, u64, u64, u64, Vec<FileStats>, std::time::Duration)> {
let pipeline_start = std::time::Instant::now();
let discovery_config = discovery::DiscoveryConfig {
fail_fast: args.fail_fast,
max_threads: args.max_cpus,
};
info!("Starting overlapped file discovery and processing in: {}", root_dir.display());
let fail_fast = args.fail_fast;
let quiet = args.quiet;
let calculate_length_stats = args.sentence_length_stats;
let mut discovery_stream = Box::pin(discovery::discover_files_parallel(root_dir, discovery_config));
let mut discovered_files = Vec::new();
let mut valid_files = Vec::new();
let mut invalid_files = Vec::new();
let mut processing_queue = VecDeque::new();
let mut processing_results = Vec::new();
let max_concurrent = if let Some(max_cpus) = args.max_cpus {
max_cpus.max(1) } else {
(num_cpus::get().saturating_sub(1)).max(1) };
let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent));
let detector = Arc::new(
crate::sentence_detector::dialog_detector::SentenceDetectorDialog::new()
.map_err(|e| anyhow::anyhow!("Failed to initialize dialog sentence detector: {}", e))?
);
while let Some(discovery_result) = discovery_stream.next().await {
match discovery_result {
Ok(file_validation) => {
discovered_files.push(file_validation.clone());
if file_validation.error.is_none() {
valid_files.push(file_validation.clone());
let semaphore_clone = semaphore.clone();
let detector_clone = detector.clone();
let path = file_validation.path.clone();
let overwrite_all = args.overwrite_all;
let debug_seams = args.debug_seams;
let should_process = should_process_file(&path, restart_log, overwrite_all).await.unwrap_or(true);
if should_process {
let task = tokio::spawn(async move {
let _permit = semaphore_clone.acquire().await.unwrap();
process_single_file_restart(
&path,
&detector_clone,
overwrite_all,
quiet,
calculate_length_stats,
debug_seams,
).await
});
processing_queue.push_back(task);
} else {
let skip_result = Ok((0u64, 0u64, 0u64, true, FileStats {
path: path.to_string_lossy().to_string(),
chars_processed: 0,
sentences_detected: 0,
sentence_length_stats: None,
processing_time_ms: 0,
sentence_detection_time_ms: 0,
chars_per_sec: 0.0,
status: "skipped".to_string(),
error: None,
}));
processing_results.push(skip_result);
}
} else {
invalid_files.push(file_validation.clone());
let validation_failure_stats = FileStats {
path: file_validation.path.to_string_lossy().to_string(),
chars_processed: 0,
sentences_detected: 0,
sentence_length_stats: None,
processing_time_ms: 0,
sentence_detection_time_ms: 0,
chars_per_sec: 0.0,
status: "failed_validation".to_string(),
error: file_validation.error,
};
processing_results.push(Ok((0, 0, 0, false, validation_failure_stats)));
}
}
Err(e) => {
if fail_fast {
return Err(e);
}
info!("Discovery error (continuing): {}", e);
}
}
while let Some(task) = processing_queue.front() {
if task.is_finished() {
let task = processing_queue.pop_front().unwrap();
match task.await {
Ok(result) => processing_results.push(result),
Err(e) => {
info!("Processing task error: {}", e);
if fail_fast {
return Err(anyhow::anyhow!("Processing task failed: {}", e));
}
}
}
} else {
break;
}
}
}
let remaining_tasks: Vec<_> = processing_queue.into_iter().collect();
let remaining_results = join_all(remaining_tasks).await;
for result in remaining_results {
match result {
Ok(processing_result) => processing_results.push(processing_result),
Err(e) => {
info!("Remaining processing task error: {}", e);
if fail_fast {
return Err(anyhow::anyhow!("Processing task failed: {}", e));
}
}
}
}
let mut total_sentences = 0u64;
let mut total_bytes = 0u64;
let mut processed_files = 0u64;
let mut skipped_files = 0u64;
let mut failed_files = 0u64;
let mut file_stats = Vec::new();
for result in processing_results {
match result {
Ok((sentences, bytes, files, skipped, stats)) => {
total_sentences += sentences;
total_bytes += bytes;
if skipped {
skipped_files += 1;
} else {
processed_files += files;
let path = std::path::PathBuf::from(&stats.path);
restart_log.mark_completed(&path);
}
file_stats.push(stats);
}
Err(e) => {
failed_files += 1;
info!("Processing error: {}", e);
let failed_stats = FileStats {
path: "unknown".to_string(),
chars_processed: 0,
sentences_detected: 0,
sentence_length_stats: None,
processing_time_ms: 0,
sentence_detection_time_ms: 0,
chars_per_sec: 0.0,
status: "failed".to_string(),
error: Some(e.to_string()),
};
file_stats.push(failed_stats);
}
}
}
if let Err(e) = restart_log.save(root_dir).await {
info!("Warning: Failed to save restart log: {}", e);
} else {
info!("Saved restart log to {}", root_dir.join(".seams_restart.json").display());
}
let processing_duration = pipeline_start.elapsed();
info!("File discovery completed: {} total files found", discovered_files.len());
info!("Valid UTF-8 files: {}", valid_files.len());
if !invalid_files.is_empty() {
info!("Files with issues: {}", invalid_files.len());
for file in &invalid_files {
if let Some(ref error) = file.error {
info!("Issue with {}: {}", file.path.display(), error);
}
}
}
if !quiet {
println!("seams v{} - Overlapped discovery and processing complete", env!("CARGO_PKG_VERSION"));
println!("Found {} files matching pattern *-0.txt", discovered_files.len());
println!("Valid files: {}, Files with issues: {}", valid_files.len(), invalid_files.len());
println!("Restart log: {} files tracked as completed", restart_log.completed_count());
if discovered_files.is_empty() {
println!("\nNo *-0.txt files found in directory tree.");
println!("SUGGESTIONS:");
println!("• Verify your directory contains Project Gutenberg files ending in '-0.txt'");
println!("• Check subdirectories - seams searches recursively");
println!("• Example valid filenames: 'pg1234-0.txt', 'alice-0.txt'");
println!("• Try: find {} -name '*-0.txt' -type f", root_dir.display());
}
}
if std::env::var("SEAMS_DEBUG_API").is_ok() && !valid_files.is_empty() {
let example_path = &valid_files[0].path;
let demo_content = "0\tExample usage of public API.\t(1,1,1,27)\n";
if create_complete_aux_file(example_path, demo_content).is_ok() {
info!("Created demo aux file using public API for {}", example_path.display());
}
}
if !quiet && !valid_files.is_empty() {
println!("File processing complete:");
println!(" Successfully processed: {processed_files} files");
if skipped_files > 0 {
println!(" Skipped (complete aux files): {skipped_files} files");
}
if failed_files > 0 {
println!(" Failed to process: {failed_files} files");
}
println!(" Total bytes processed: {total_bytes}");
println!(" Total sentences detected: {total_sentences}");
if args.sentence_length_stats {
if let Some(ref length_stats) = crate::parallel_processing::calculate_aggregate_sentence_length_stats(&file_stats) {
println!(" Sentence length statistics:");
println!(" Min: {} chars, Max: {} chars", length_stats.min_length, length_stats.max_length);
println!(" Mean: {:.1} chars, Median: {:.1} chars", length_stats.mean_length, length_stats.median_length);
println!(" P25: {:.1}, P75: {:.1}, P90: {:.1} chars", length_stats.p25_length, length_stats.p75_length, length_stats.p90_length);
println!(" Std Dev: {:.1} chars", length_stats.std_dev);
}
}
println!(" Total time spent: {:.2}s", processing_duration.as_secs_f64());
if total_bytes > 0 && processing_duration.as_secs_f64() > 0.0 {
let throughput_chars_per_sec = total_bytes as f64 / processing_duration.as_secs_f64();
let throughput_mb_per_sec = throughput_chars_per_sec / 1_000_000.0;
println!(" Overall throughput: {throughput_chars_per_sec:.0} chars/sec ({throughput_mb_per_sec:.2} MB/s)");
let total_sentence_detection_time_ms: u64 = file_stats.iter()
.map(|fs| fs.sentence_detection_time_ms)
.sum();
if total_sentence_detection_time_ms > 0 && total_sentences > 0 {
let sentence_detection_time_sec = total_sentence_detection_time_ms as f64 / 1000.0;
let sentence_detection_throughput_chars_per_sec = total_bytes as f64 / sentence_detection_time_sec;
let sentence_detection_throughput_mb_per_sec = sentence_detection_throughput_chars_per_sec / 1_000_000.0;
println!(" Sentence detection throughput: {sentence_detection_throughput_chars_per_sec:.0} chars/sec ({sentence_detection_throughput_mb_per_sec:.2} MB/s)");
println!(" Sentence detection time: {sentence_detection_time_sec:.2}s of {:.2}s total ({:.1}%)",
processing_duration.as_secs_f64(),
(sentence_detection_time_sec / processing_duration.as_secs_f64()) * 100.0);
}
}
}
info!("Overlapped pipeline completed: {} processed, {} skipped, {} failed, {} sentences detected",
processed_files, skipped_files, failed_files, total_sentences);
Ok((total_sentences, total_bytes, processed_files, skipped_files, failed_files, file_stats, processing_duration))
}
async fn process_single_file_restart(
path: &std::path::Path,
detector: &crate::sentence_detector::dialog_detector::SentenceDetectorDialog,
_overwrite_all: bool,
quiet: bool,
calculate_length_stats: bool,
debug_seams: bool,
) -> Result<(u64, u64, u64, bool, FileStats)> {
let start_time = std::time::Instant::now();
let file = std::fs::File::open(path)
.map_err(|e| anyhow::anyhow!(
"Cannot read file: {}\nError: {}\n\nSUGGESTIONS:\n• Check file permissions (should be readable)\n• Ensure file is not locked by another process\n• Verify disk space is available",
path.display(), e
))?;
let mmap = unsafe { memmap2::MmapOptions::new().map(&file) }
.map_err(|e| anyhow::anyhow!(
"Cannot memory-map file: {}\nError: {}\n\nSUGGESTIONS:\n• File may be too large for available memory\n• Check file is not corrupted\n• Ensure sufficient virtual memory is available",
path.display(), e
))?;
let content = std::str::from_utf8(&mmap)
.map_err(|_| anyhow::anyhow!(
"File contains invalid UTF-8: {}\n\nSUGGESTIONS:\n• Ensure file is a text file, not binary\n• Check file encoding - Project Gutenberg files should be UTF-8\n• File may be corrupted during download",
path.display()
))?;
let sentence_detection_start = std::time::Instant::now();
let (sentences, debug_info) = if debug_seams {
detector.detect_sentences_borrowed_with_debug(content)
.map_err(|e| anyhow::anyhow!(
"Sentence detection failed for: {}\nError: {}\n\nSUGGESTIONS:\n• File may contain unusual text patterns that confuse the detector\n• Ensure file contains valid Project Gutenberg text format\n• Check file is not corrupted or truncated",
path.display(), e
))?
} else {
let sentences = detector.detect_sentences_borrowed(content)
.map_err(|e| anyhow::anyhow!(
"Sentence detection failed for: {}\nError: {}\n\nSUGGESTIONS:\n• File may contain unusual text patterns that confuse the detector\n• Ensure file contains valid Project Gutenberg text format\n• Check file is not corrupted or truncated",
path.display(), e
))?;
(sentences, Vec::new())
};
let sentence_detection_time = sentence_detection_start.elapsed();
let sentence_count = sentences.len() as u64;
let byte_count = content.len() as u64;
let char_count = content.chars().count() as u64;
let aux_path = crate::incremental::generate_aux_file_path(path);
let debug_info_opt = if debug_seams && !debug_info.is_empty() {
Some(debug_info.as_slice())
} else {
None
};
crate::parallel_processing::write_auxiliary_file_borrowed(&aux_path, &sentences, detector, debug_seams, debug_info_opt).await?;
let sentence_length_stats = if calculate_length_stats {
crate::parallel_processing::calculate_sentence_length_stats(&sentences)
} else {
None
};
let processing_time = start_time.elapsed();
let processing_time_ms = processing_time.as_millis() as u64;
let chars_per_sec = if processing_time.as_secs_f64() > 0.0 {
char_count as f64 / processing_time.as_secs_f64()
} else {
0.0
};
let file_stats = FileStats {
path: path.to_string_lossy().to_string(),
chars_processed: char_count,
sentences_detected: sentence_count,
sentence_length_stats,
processing_time_ms,
sentence_detection_time_ms: sentence_detection_time.as_millis() as u64,
chars_per_sec,
status: "success".to_string(),
error: None,
};
let detection_ms = sentence_detection_time.as_millis();
if !quiet {
println!(
"[Processed {}: {} sentences, {} bytes, detection {} ms",
path.display(),
sentence_count,
byte_count,
detection_ms
);
}
Ok((sentence_count, byte_count, 1u64, false, file_stats))
}
async fn process_single_file_mode(
file_path: &std::path::Path,
args: &Args,
) -> Result<()> {
if !file_path.exists() {
anyhow::bail!(
"File not found: {}\n\nSUGGESTIONS:\n• Check the file path spelling\n• Ensure you have read permissions for the file\n• Use an absolute path if using relative paths",
file_path.display()
);
}
if !file_path.is_file() {
anyhow::bail!(
"Path exists but is not a file: {}\n\nSUGGESTIONS:\n• Provide a file path, not a directory path\n• Use seams with a directory to process multiple files\n• Example: seams /path/to/file-0.txt",
file_path.display()
);
}
if !args.quiet {
println!("seams v{} - Processing single file", env!("CARGO_PKG_VERSION"));
println!("File: {}", file_path.display());
}
let start_time = std::time::Instant::now();
let detector = crate::sentence_detector::dialog_detector::SentenceDetectorDialog::new()
.map_err(|e| anyhow::anyhow!("Failed to initialize dialog sentence detector: {}", e))?;
let result = process_single_file_restart(
file_path,
&detector,
args.overwrite_all,
args.quiet,
args.sentence_length_stats,
args.debug_seams,
).await;
match result {
Ok((sentence_count, byte_count, _, _, file_stats)) => {
let processing_duration = start_time.elapsed();
if !args.quiet {
println!("Single file processing complete:");
println!(" Successfully processed: 1 file");
println!(" Total bytes processed: {byte_count}");
println!(" Total sentences detected: {sentence_count}");
if args.sentence_length_stats {
if let Some(ref length_stats) = file_stats.sentence_length_stats {
println!(" Sentence length statistics:");
println!(" Min: {} chars, Max: {} chars", length_stats.min_length, length_stats.max_length);
println!(" Mean: {:.1} chars, Median: {:.1} chars", length_stats.mean_length, length_stats.median_length);
println!(" P25: {:.1}, P75: {:.1}, P90: {:.1} chars", length_stats.p25_length, length_stats.p75_length, length_stats.p90_length);
println!(" Std Dev: {:.1} chars", length_stats.std_dev);
}
}
println!(" Total time spent: {:.2}s", processing_duration.as_secs_f64());
if byte_count > 0 && processing_duration.as_secs_f64() > 0.0 {
let throughput_chars_per_sec = byte_count as f64 / processing_duration.as_secs_f64();
let throughput_mb_per_sec = throughput_chars_per_sec / 1_000_000.0;
println!(" Overall throughput: {throughput_chars_per_sec:.0} chars/sec ({throughput_mb_per_sec:.2} MB/s)");
if file_stats.sentence_detection_time_ms > 0 {
let sentence_detection_time_sec = file_stats.sentence_detection_time_ms as f64 / 1000.0;
let sentence_detection_throughput_chars_per_sec = byte_count as f64 / sentence_detection_time_sec;
let sentence_detection_throughput_mb_per_sec = sentence_detection_throughput_chars_per_sec / 1_000_000.0;
println!(" Sentence detection throughput: {sentence_detection_throughput_chars_per_sec:.0} chars/sec ({sentence_detection_throughput_mb_per_sec:.2} MB/s)");
}
}
}
let overall_chars_per_sec = if processing_duration.as_secs_f64() > 0.0 {
byte_count as f64 / processing_duration.as_secs_f64()
} else {
0.0
};
let sentence_detection_chars_per_sec = if file_stats.sentence_detection_time_ms > 0 {
byte_count as f64 / (file_stats.sentence_detection_time_ms as f64 / 1000.0)
} else {
0.0
};
let file_stats_vec = vec![file_stats];
let aggregate_sentence_length_stats = crate::parallel_processing::calculate_aggregate_sentence_length_stats(&file_stats_vec);
let run_stats = RunStats {
run_start: SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs().to_string(),
total_processing_time_ms: processing_duration.as_millis() as u64,
total_sentence_detection_time_ms: file_stats_vec[0].sentence_detection_time_ms,
total_chars_processed: byte_count,
total_sentences_detected: sentence_count,
aggregate_sentence_length_stats,
overall_chars_per_sec,
sentence_detection_chars_per_sec,
files_processed: 1,
files_skipped: 0,
files_failed: 0,
file_stats: file_stats_vec,
};
match serde_json::to_string_pretty(&run_stats) {
Ok(json_content) => {
match tokio::fs::write(&args.stats_out, json_content).await {
Ok(()) => {
info!("Stats written to {}", args.stats_out.display());
if !args.quiet {
println!("Stats written to {}", args.stats_out.display());
}
}
Err(e) => {
info!("Warning: Failed to write stats file: {e}");
if !args.quiet {
println!("Warning: Failed to write stats file: {e}");
}
}
}
}
Err(e) => {
info!("Warning: Failed to serialize stats: {e}");
if !args.quiet {
println!("Warning: Failed to serialize stats: {e}");
}
}
}
Ok(())
}
Err(e) => {
if !args.quiet {
println!("Failed to process file: {e}");
}
Err(e)
}
}
}
fn write_debug_output_to_stdout(
sentences: &[crate::sentence_detector::DetectedSentenceBorrowed<'_>],
debug_info: Option<&[crate::sentence_detector::dialog_detector::DebugTransitionInfo]>,
) {
if let Some(debug_transitions) = debug_info {
for (sentence_idx, sentence) in sentences.iter().enumerate() {
let sentence_transitions: Vec<_> = debug_transitions.iter()
.filter(|t| t.sentence_index == sentence_idx)
.collect();
if sentence_transitions.is_empty() {
println!(
"{}\t{}\t({},{},{},{})\tUnknown\tUnknown\tUnknown\tno_pattern\tno_pattern_name\tno_seam_text",
sentence.index,
sentence.normalize(),
sentence.span.start_line,
sentence.span.start_col,
sentence.span.end_line,
sentence.span.end_col, );
} else {
for transition in sentence_transitions {
println!(
"{}\t{}\t({},{},{},{})\t{:?}\t{:?}\t{:?}\t{}\t{}\t{}",
sentence.index,
sentence.normalize(),
sentence.span.start_line,
sentence.span.start_col,
sentence.span.end_line,
sentence.span.end_col,
transition.state_before,
transition.state_after,
transition.transition_type,
transition.matched_pattern.replace('\n', "\\n").replace('\t', "\\t"), transition.pattern_name,
transition.seam_text.replace('\n', "\\n").replace('\t', "\\t"), );
}
}
}
} else {
for sentence in sentences {
println!(
"{}\t{}\t({},{},{},{})\tplaceholder_state_before\tplaceholder_state_after\tplaceholder_transition_type\tplaceholder_pattern\tplaceholder_name\tplaceholder_seam",
sentence.index,
sentence.normalize(),
sentence.span.start_line,
sentence.span.start_col,
sentence.span.end_line,
sentence.span.end_col, );
}
}
}
async fn process_debug_mode(text: &str) -> Result<()> {
let detector = crate::sentence_detector::dialog_detector::SentenceDetectorDialog::new()
.map_err(|e| anyhow::anyhow!("Failed to initialize dialog sentence detector: {}", e))?;
let (sentences, debug_info) = detector.detect_sentences_borrowed_with_debug(text)
.map_err(|e| anyhow::anyhow!("Sentence detection failed: {}", e))?;
write_debug_output_to_stdout(&sentences, Some(&debug_info));
Ok(())
}
#[derive(Parser, Debug)]
#[command(name = "seams")]
#[command(about = "High-throughput sentence extractor for Project Gutenberg texts")]
#[command(long_about = "Seams is a high-performance CLI tool for extracting sentences from Project Gutenberg texts.
It recursively scans for *-0.txt files, detects sentence boundaries using a dialog-aware
sentence detector, and outputs normalized sentences with span metadata to _seams.txt files.
Designed for narrative analysis pipelines.
BASIC USAGE:
seams /path/to/gutenberg/ # Process all *-0.txt files in directory tree
seams ~/Downloads/gutenberg-mirror/ # Process entire Gutenberg mirror
seams /path/to/single-file-0.txt # Process a single *-0.txt file
COMMON WORKFLOWS:
seams ./texts --overwrite-all # Reprocess all files (ignore existing outputs)
seams ./texts --fail-fast # Stop immediately on any error
seams ./texts --quiet # Minimal output for scripts/automation
PERFORMANCE & DEBUGGING:
seams ./texts --stats-out benchmark.json # Save detailed performance metrics
seams ./texts --clear-restart-log # Clear resume state, reprocess everything
seams ./texts --max-cpus 1 # Single-CPU benchmarking for baselines
seams ./texts --max-cpus 4 # Limit to 4 CPU cores
EXPECTED BEHAVIOR:
• Finds files matching *-0.txt pattern recursively
• Creates _seams.txt files alongside each input file
• Resumes interrupted runs automatically (use --overwrite-all to disable)
• Outputs performance statistics and file counts
TROUBLESHOOTING:
• No files found? Check your directory contains *-0.txt files
• Permission errors? Ensure write access to input directory
• Want to reprocess? Use --overwrite-all or --clear-restart-log")]
#[command(version)]
struct Args {
#[arg(value_name = "PATH", help = "Directory to scan recursively for *-0.txt files, or single *-0.txt file to process")]
root_dir: Option<PathBuf>,
#[arg(long, help = "Reprocess all files, even those with complete _seams.txt files")]
overwrite_all: bool,
#[arg(long, help = "Stop processing immediately on first I/O, UTF-8, or detection error")]
fail_fast: bool,
#[arg(long, short = 'q', help = "Suppress all non-error output")]
quiet: bool,
#[arg(long, default_value = "run_stats.json", value_name = "FILE", help = "Write performance statistics to JSON file")]
stats_out: PathBuf,
#[arg(long, help = "Clear the restart log and reprocess all files")]
clear_restart_log: bool,
#[arg(long, help = "Limit processing to specified number of CPUs/threads (default: use all available)")]
max_cpus: Option<usize>,
#[arg(long, help = "Calculate and display sentence length statistics (min, max, mean, median, percentiles)")]
sentence_length_stats: bool,
#[arg(long, help = "Generate debug TSV files with state transition and pattern match details")]
debug_seams: bool,
#[arg(long, help = "Debug sentence detection on provided text string (outputs to stdout)")]
debug_text: Option<String>,
#[arg(long, help = "Debug sentence detection on text from stdin (outputs to stdout)")]
debug_stdin: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_target(false)
.json()
.with_writer(std::io::stderr)
.init();
let args = Args::parse();
info!("Starting seams");
info!(?args, "Parsed CLI arguments");
if args.debug_text.is_some() || args.debug_stdin {
if args.debug_text.is_some() && args.debug_stdin {
anyhow::bail!("Cannot use both --debug-text and --debug-stdin flags simultaneously");
}
let text = if let Some(debug_text) = &args.debug_text {
debug_text.clone()
} else {
use tokio::io::{AsyncReadExt, stdin};
let mut buffer = String::new();
stdin().read_to_string(&mut buffer).await
.map_err(|e| anyhow::anyhow!("Failed to read from stdin: {}", e))?;
buffer
};
return process_debug_mode(&text).await;
}
let root_dir = args.root_dir.as_ref().ok_or_else(|| {
anyhow::anyhow!("PATH argument is required when not using debug flags (--debug-text or --debug-stdin)")
})?;
if !root_dir.exists() {
anyhow::bail!(
"Directory not found: {}\n\nSUGGESTIONS:\n• Check the path spelling and try again\n• Ensure you have read permissions for the parent directory\n• Use an absolute path like '/home/user/gutenberg' if using relative paths",
root_dir.display()
);
}
if root_dir.is_file() {
return process_single_file_mode(root_dir, &args).await;
}
if !root_dir.is_dir() {
anyhow::bail!(
"Path exists but is neither a file nor a directory: {}\n\nSUGGESTIONS:\n• Provide a directory path to process multiple files\n• Provide a single *-0.txt file path to process one file\n• Example: seams /path/to/gutenberg-texts/ or seams /path/to/file-0.txt",
root_dir.display()
);
}
info!("Project setup validation completed successfully");
let mut restart_log = RestartLog::load(root_dir).await;
if args.clear_restart_log {
let cleared_count = restart_log.completed_count();
restart_log.clear();
info!("Cleared {} entries from restart log", cleared_count);
if !args.quiet {
println!("Restart log cleared - will reprocess all files");
}
} else {
let initial_count = restart_log.completed_count();
if initial_count > 0 {
info!("Loaded restart log with {} completed files", initial_count);
let invalid_files = restart_log.verify_completed_files().await?;
if !invalid_files.is_empty() {
info!("Cleaned {} stale entries from restart log", invalid_files.len());
}
}
}
let (total_sentences, total_bytes, processed_files, skipped_files, failed_files, file_stats, processing_duration) =
process_with_overlapped_pipeline(root_dir, &args, &mut restart_log).await?;
let overall_chars_per_sec = if processing_duration.as_secs_f64() > 0.0 {
total_bytes as f64 / processing_duration.as_secs_f64()
} else {
0.0
};
let total_sentence_detection_time_ms: u64 = file_stats.iter()
.map(|fs| fs.sentence_detection_time_ms)
.sum();
let sentence_detection_chars_per_sec = if total_sentence_detection_time_ms > 0 {
total_bytes as f64 / (total_sentence_detection_time_ms as f64 / 1000.0)
} else {
0.0
};
let aggregate_sentence_length_stats = if args.sentence_length_stats {
crate::parallel_processing::calculate_aggregate_sentence_length_stats(&file_stats)
} else {
None
};
let run_stats = RunStats {
run_start: SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs().to_string(),
total_processing_time_ms: processing_duration.as_millis() as u64,
total_sentence_detection_time_ms,
total_chars_processed: total_bytes,
total_sentences_detected: total_sentences,
aggregate_sentence_length_stats,
overall_chars_per_sec,
sentence_detection_chars_per_sec,
files_processed: processed_files,
files_skipped: skipped_files,
files_failed: failed_files,
file_stats,
};
match serde_json::to_string_pretty(&run_stats) {
Ok(json_content) => {
match tokio::fs::write(&args.stats_out, json_content).await {
Ok(()) => {
info!("Stats written to {}", args.stats_out.display());
if !args.quiet {
println!("Stats written to {}", args.stats_out.display());
}
}
Err(e) => {
info!("Warning: Failed to write stats file: {e}");
if !args.quiet {
println!("Warning: Failed to write stats file: {e}");
}
}
}
}
Err(e) => {
info!("Warning: Failed to serialize stats: {e}");
if !args.quiet {
println!("Warning: Failed to serialize stats: {e}");
}
}
}
Ok(())
}