Skip to main content

sakurs_cli/commands/
process.rs

1//! Process command implementation
2
3use anyhow::{Context, Result};
4use clap::Args;
5use std::path::PathBuf;
6
7/// Arguments for the process command
8#[derive(Debug, Args)]
9pub struct ProcessArgs {
10    /// Input files or patterns (supports glob, use '-' for stdin)
11    #[arg(short, long, value_name = "FILE/PATTERN", required = true)]
12    pub input: Vec<String>,
13
14    /// Output file (default: stdout)
15    #[arg(short, long, value_name = "FILE")]
16    pub output: Option<PathBuf>,
17
18    /// Output format
19    #[arg(short, long, value_enum, default_value = "text")]
20    pub format: OutputFormat,
21
22    /// Language for sentence detection rules
23    /// NOTE: Mutually exclusive with --language-config
24    #[arg(short, long, value_enum, conflicts_with = "language_config")]
25    pub language: Option<Language>,
26
27    /// Path to external language configuration file (TOML format)
28    /// NOTE: Mutually exclusive with --language
29    #[arg(short = 'c', long, value_name = "FILE", conflicts_with = "language")]
30    pub language_config: Option<PathBuf>,
31
32    /// Language code for external configuration (optional)
33    /// NOTE: Only used with --language-config
34    #[arg(long, requires = "language_config")]
35    pub language_code: Option<String>,
36
37    /// Force parallel processing even for small files
38    #[arg(short, long)]
39    pub parallel: bool,
40
41    /// Use adaptive processing (automatically choose best strategy)
42    /// Note: This is experimental and currently uses the default processing
43    #[arg(long, conflicts_with = "parallel")]
44    pub adaptive: bool,
45
46    /// Number of threads for parallel processing (default: auto)
47    #[arg(short = 't', long, value_name = "COUNT")]
48    pub threads: Option<usize>,
49
50    /// Chunk size in KB for parallel processing (default: 256)
51    #[arg(long, value_name = "SIZE_KB")]
52    pub chunk_kb: Option<usize>,
53
54    /// Suppress progress output
55    #[arg(short, long)]
56    pub quiet: bool,
57
58    /// Increase verbosity
59    #[arg(short, long, action = clap::ArgAction::Count)]
60    pub verbose: u8,
61
62    /// Enable streaming mode for large files (process in chunks)
63    #[arg(long)]
64    pub stream: bool,
65
66    /// Streaming chunk size in MB (default: 10MB)
67    #[arg(long, default_value = "10", requires = "stream")]
68    pub stream_chunk_mb: u64,
69}
70
71/// Supported output formats
72#[derive(Debug, Clone, Copy, clap::ValueEnum)]
73pub enum OutputFormat {
74    /// Plain text with one sentence per line
75    #[value(alias = "txt")]
76    Text,
77    /// JSON array of sentences with metadata
78    Json,
79    /// Markdown formatted output
80    #[value(alias = "md")]
81    Markdown,
82}
83
84/// Supported languages
85#[derive(Debug, Clone, Copy, clap::ValueEnum)]
86pub enum Language {
87    /// English language rules
88    #[value(alias = "en", alias = "eng")]
89    English,
90    /// Japanese language rules
91    #[value(alias = "ja", alias = "jpn")]
92    Japanese,
93}
94
95impl ProcessArgs {
96    /// Execute the process command
97    pub fn execute(&self) -> Result<()> {
98        // Initialize logging based on verbosity
99        self.init_logging()?;
100
101        log::info!("Starting text processing");
102        log::debug!("Arguments: {self:?}");
103
104        // Create output formatter
105        let mut formatter: Box<dyn crate::output::OutputFormatter> = self.create_formatter()?;
106
107        // Create processor
108        let processor = self.create_processor()?;
109
110        // Check if input is stdin
111        if self.input.len() == 1 && self.input[0] == "-" {
112            log::info!("Reading from stdin");
113            self.process_stdin(&processor, &mut formatter)?;
114        } else {
115            // Resolve file patterns
116            let files = crate::input::resolve_patterns(&self.input)?;
117            log::info!("Found {} files to process", files.len());
118
119            // Initialize progress reporter
120            let mut progress = crate::progress::ProgressReporter::new(self.quiet);
121            progress.init_files(files.len() as u64);
122
123            for file in &files {
124                log::info!("Processing file: {}", file.display());
125
126                // Check if we should use streaming mode
127                let file_size_mb = crate::input::FileReader::file_size(file)? / (1024 * 1024);
128                let should_stream = self.stream || file_size_mb > 100; // Auto-stream for files > 100MB
129
130                if should_stream {
131                    log::info!(
132                        "Using streaming mode for {} ({}MB)",
133                        file.display(),
134                        file_size_mb
135                    );
136                    self.process_file_streaming(file, &processor, &mut formatter)?;
137                } else {
138                    // Read entire file content
139                    let content = crate::input::FileReader::read_text(file)?;
140
141                    // Process text
142                    let result = processor
143                        .process(sakurs_core::Input::from_text(content.clone()))
144                        .map_err(|e| anyhow::anyhow!("Processing failed: {}", e))?;
145
146                    // Extract and output sentences
147                    let mut last_offset = 0;
148                    for boundary in &result.boundaries {
149                        let sentence = &content[last_offset..boundary.offset];
150                        formatter.format_sentence(sentence.trim(), last_offset)?;
151                        last_offset = boundary.offset;
152                    }
153
154                    // Don't forget the last sentence after the final boundary
155                    if last_offset < content.len() {
156                        let sentence = &content[last_offset..];
157                        if !sentence.trim().is_empty() {
158                            formatter.format_sentence(sentence.trim(), last_offset)?;
159                        }
160                    }
161                }
162
163                progress.file_completed(&file.file_name().unwrap_or_default().to_string_lossy());
164            }
165
166            progress.finish();
167            log::info!("Processing complete. Processed {} files", files.len());
168        }
169
170        // Finalize output
171        formatter.finish()?;
172        Ok(())
173    }
174
175    /// Initialize logging based on verbosity level
176    fn init_logging(&self) -> Result<()> {
177        let log_level = match self.verbose {
178            0 => "warn",
179            1 => "info",
180            2 => "debug",
181            _ => "trace",
182        };
183
184        if !self.quiet {
185            env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(log_level))
186                .init();
187        }
188
189        Ok(())
190    }
191
192    /// Create appropriate output formatter based on format option
193    fn create_formatter(&self) -> Result<Box<dyn crate::output::OutputFormatter>> {
194        use std::io;
195
196        match self.format {
197            OutputFormat::Text => {
198                if let Some(output_path) = &self.output {
199                    let file = std::fs::File::create(output_path).with_context(|| {
200                        format!("Failed to create output file: {}", output_path.display())
201                    })?;
202                    Ok(Box::new(crate::output::TextFormatter::new(file)))
203                } else {
204                    Ok(Box::new(crate::output::TextFormatter::new(io::stdout())))
205                }
206            }
207            OutputFormat::Json => {
208                if let Some(output_path) = &self.output {
209                    let file = std::fs::File::create(output_path).with_context(|| {
210                        format!("Failed to create output file: {}", output_path.display())
211                    })?;
212                    Ok(Box::new(crate::output::JsonFormatter::new(file)))
213                } else {
214                    Ok(Box::new(crate::output::JsonFormatter::new(io::stdout())))
215                }
216            }
217            OutputFormat::Markdown => {
218                if let Some(output_path) = &self.output {
219                    let file = std::fs::File::create(output_path).with_context(|| {
220                        format!("Failed to create output file: {}", output_path.display())
221                    })?;
222                    Ok(Box::new(crate::output::MarkdownFormatter::new(file)))
223                } else {
224                    Ok(Box::new(
225                        crate::output::MarkdownFormatter::new(io::stdout()),
226                    ))
227                }
228            }
229        }
230    }
231
232    /// Create text processor with appropriate language rules
233    fn create_processor(&self) -> Result<sakurs_core::SentenceProcessor> {
234        use crate::language_source::LanguageSource;
235        use sakurs_core::{Config, SentenceProcessor};
236
237        // Determine language source
238        let language_source = match (&self.language, &self.language_config) {
239            (Some(lang), None) => LanguageSource::BuiltIn(*lang),
240            (None, Some(path)) => LanguageSource::External {
241                path: path.clone(),
242                language_code: self.language_code.clone(),
243            },
244            (None, None) => LanguageSource::BuiltIn(Language::English), // Default
245            (Some(_), Some(_)) => unreachable!(),                       // clap handles conflicts
246        };
247
248        log::info!("Using language source: {}", language_source.display_name());
249
250        // Create processor based on language source
251        match language_source {
252            LanguageSource::BuiltIn(lang) => {
253                let language_code = lang.code();
254
255                // Build configuration with thread option handling
256                let builder = Config::builder()
257                    .language(language_code)
258                    .map_err(|e| anyhow::anyhow!("Failed to set language: {}", e))?;
259
260                let builder = self.configure_builder(builder)?;
261
262                let config = builder
263                    .build()
264                    .map_err(|e| anyhow::anyhow!("Failed to build processor config: {}", e))?;
265
266                SentenceProcessor::with_config(config)
267                    .map_err(|e| anyhow::anyhow!("Failed to create processor: {}", e))
268            }
269            LanguageSource::External {
270                path,
271                language_code,
272            } => {
273                // Load external configuration
274                use sakurs_core::LanguageConfig;
275
276                let language =
277                    LanguageConfig::from_file(&path, language_code.as_deref()).map_err(|e| {
278                        anyhow::anyhow!("Failed to load external language config: {}", e)
279                    })?;
280
281                // Build configuration
282                let builder = Config::builder();
283                let builder = self.configure_builder(builder)?;
284
285                let config = builder
286                    .build()
287                    .map_err(|e| anyhow::anyhow!("Failed to build processor config: {}", e))?;
288
289                // Create processor with the custom language configuration
290                SentenceProcessor::with_language_config(config, &language)
291                    .map_err(|e| anyhow::anyhow!("Failed to create processor: {}", e))
292            }
293        }
294    }
295
296    /// Configure the builder with common options
297    fn configure_builder(
298        &self,
299        builder: sakurs_core::ConfigBuilder,
300    ) -> Result<sakurs_core::ConfigBuilder> {
301        let mut builder = builder;
302
303        // Handle thread count:
304        // - If threads is specified, use that value
305        // - If parallel flag is set, use None (all available threads)
306        // - Otherwise, use default (auto-detect based on text size)
307        if let Some(thread_count) = self.threads {
308            if thread_count == 0 {
309                return Err(anyhow::anyhow!("Thread count must be greater than 0"));
310            }
311            builder = builder.threads(Some(thread_count));
312        } else if self.parallel {
313            builder = builder.threads(None); // Use all available threads
314        }
315
316        // Handle chunk size if specified
317        if let Some(chunk_kb) = self.chunk_kb {
318            if chunk_kb == 0 {
319                return Err(anyhow::anyhow!("Chunk size must be greater than 0"));
320            }
321            // Convert KB to bytes
322            let chunk_size = chunk_kb * 1024;
323            builder = builder.chunk_size(chunk_size);
324        }
325
326        // Note: adaptive mode now uses default configuration
327        Ok(builder)
328    }
329
330    /// Process a file in streaming mode
331    fn process_file_streaming(
332        &self,
333        file: &std::path::Path,
334        processor: &sakurs_core::SentenceProcessor,
335        formatter: &mut Box<dyn crate::output::OutputFormatter>,
336    ) -> Result<()> {
337        // For now, streaming mode uses the same processing as regular mode
338        // but could be enhanced in the future to process chunks incrementally
339        log::info!("Using streaming mode for large file: {}", file.display());
340
341        let content = crate::input::FileReader::read_text(file)?;
342        let result = processor
343            .process(sakurs_core::Input::from_text(content.clone()))
344            .map_err(|e| anyhow::anyhow!("Processing failed: {}", e))?;
345
346        let mut last_offset = 0;
347        for boundary in &result.boundaries {
348            let sentence = &content[last_offset..boundary.offset];
349            formatter.format_sentence(sentence.trim(), last_offset)?;
350            last_offset = boundary.offset;
351        }
352
353        // Don't forget the last sentence after the final boundary
354        if last_offset < content.len() {
355            let sentence = &content[last_offset..];
356            if !sentence.trim().is_empty() {
357                formatter.format_sentence(sentence.trim(), last_offset)?;
358            }
359        }
360
361        Ok(())
362    }
363
364    /// Process stdin
365    fn process_stdin(
366        &self,
367        processor: &sakurs_core::SentenceProcessor,
368        formatter: &mut Box<dyn crate::output::OutputFormatter>,
369    ) -> Result<()> {
370        use std::io::Read;
371
372        let mut buffer = String::new();
373        std::io::stdin()
374            .read_to_string(&mut buffer)
375            .context("Failed to read from stdin")?;
376
377        let result = processor
378            .process(sakurs_core::Input::from_text(buffer.clone()))
379            .map_err(|e| anyhow::anyhow!("Processing failed: {}", e))?;
380
381        let mut last_offset = 0;
382        for boundary in &result.boundaries {
383            let sentence = &buffer[last_offset..boundary.offset];
384            formatter.format_sentence(sentence.trim(), last_offset)?;
385            last_offset = boundary.offset;
386        }
387
388        // Don't forget the last sentence after the final boundary
389        if last_offset < buffer.len() {
390            let sentence = &buffer[last_offset..];
391            if !sentence.trim().is_empty() {
392                formatter.format_sentence(sentence.trim(), last_offset)?;
393            }
394        }
395
396        Ok(())
397    }
398}
399
400/// Find a safe point to split text (prefer sentence boundary, then word boundary)
401#[allow(dead_code)]
402fn find_safe_split_point(text: &str, target: usize) -> usize {
403    if text.len() <= target {
404        return text.len();
405    }
406
407    // Look for sentence boundaries near the target
408    let search_start = target.saturating_sub(200);
409    let search_end = (target + 200).min(text.len());
410
411    if let Some(pos) = text[search_start..search_end].rfind(['.', '!', '?', '。', '!', '?']) {
412        let boundary = search_start + pos + 1;
413        if boundary <= text.len() && text.is_char_boundary(boundary) {
414            return boundary;
415        }
416    }
417
418    // Fallback to word boundary
419    let mut search_end = target.min(text.len());
420    // Ensure search_end is at a valid UTF-8 boundary
421    while search_end > 0 && !text.is_char_boundary(search_end) {
422        search_end -= 1;
423    }
424
425    if search_end > 0 {
426        if let Some(pos) = text[..search_end].rfind(|c: char| c.is_whitespace()) {
427            return pos + 1;
428        }
429    }
430
431    // Last resort: find valid UTF-8 boundary at or before target
432    let mut pos = target.min(text.len());
433    while pos > 0 && !text.is_char_boundary(pos) {
434        pos -= 1;
435    }
436    pos
437}
438
439/// Output sentences from processing result
440#[allow(dead_code)]
441fn output_sentences(
442    text: &str,
443    result: &sakurs_core::Output,
444    formatter: &mut Box<dyn crate::output::OutputFormatter>,
445    base_offset: usize,
446) -> Result<()> {
447    let mut last_offset = 0;
448    for boundary in &result.boundaries {
449        let sentence = &text[last_offset..boundary.offset];
450        formatter.format_sentence(sentence.trim(), base_offset + last_offset)?;
451        last_offset = boundary.offset;
452    }
453
454    Ok(())
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    #[test]
462    fn test_find_safe_split_point_sentence_boundary() {
463        // Test 1: Small text - finds last period in range
464        let text = "First. Second sentence here.";
465        let target = 10;
466        let split = find_safe_split_point(text, target);
467        // With target=10, search range is 0-210, covers whole text
468        // rfind finds LAST period at position 27, returns 28
469        assert_eq!(split, 28);
470        assert_eq!(&text[..split], text); // Entire text
471
472        // Test 2: Longer text where search window matters
473        let long_text = concat!(
474            "This is a sentence. ", // Period at 18
475            "Another sentence. ",   // Period at 35
476            "Third sentence. ",     // Period at 50
477            "Fourth sentence. ",    // Period at 66
478            "Fifth sentence. ",     // Period at 81
479            "Sixth sentence. ",     // Period at 96
480            "Seventh sentence."     // Period at 112
481        );
482
483        // Target 60: search window 0-260 (covers all), finds last period
484        let split = find_safe_split_point(long_text, 60);
485        println!("Long text len: {}, split: {}", long_text.len(), split);
486        // The text is 113 chars total, last char is a period
487        // So the split should be at 113 (after the last period)
488        assert_eq!(split, long_text.len()); // After last period
489
490        // Test 3: Force fallback to word boundary
491        let text3 = "This is a very long sentence without any periods until way at the end.";
492        let target3 = 20;
493        let split3 = find_safe_split_point(text3, target3);
494        // Period is at position 70, outside search range [0, 220]
495        // Wait, that's IN the search range. Let me check...
496        println!("Text3 period position: {}", text3.find('.').unwrap());
497        println!("Target3: {}, Split3: {}", target3, split3);
498        // Period is at 69, which is within search range 0-220
499        // So it should find the period, not fall back to word boundary
500        assert_eq!(split3, 70); // After the period
501    }
502
503    #[test]
504    fn test_find_safe_split_point_japanese_sentence() {
505        // Test 1: Small Japanese text
506        let text = "短い文。次の文。";
507        let target = 12;
508        let split = find_safe_split_point(text, target);
509        println!("Japanese text bytes: {}", text.len());
510        println!("Target: {}, Split: {}", target, split);
511
512        // The text "短い文。次の文。" has two 。characters
513        // Each Japanese character is 3 bytes, 。is also 3 bytes
514        // "短い文。" = 4 chars * 3 bytes = 12 bytes
515        // So first 。is at bytes 9-11, split would be 12
516        assert_eq!(split, 12); // It's actually finding the first one due to the search range
517
518        // Test 2: No sentence boundary, no spaces (Japanese doesn't use spaces)
519        let text2 = "これはとても長い日本語の文章で句読点がありません";
520        let target2 = 30;
521        let split2 = find_safe_split_point(text2, target2);
522
523        // No periods or spaces, should find UTF-8 boundary at or before target
524        assert!(text2.is_char_boundary(split2));
525        assert!(split2 <= target2);
526
527        // Test 3: Japanese text with proper sentence boundaries
528        let text3 = "最初の文。二番目。三番目。";
529        let target3 = 50; // Make target larger to ensure we find a sentence boundary
530        let split3 = find_safe_split_point(text3, target3);
531        println!(
532            "Text3 len: {}, target: {}, split: {}",
533            text3.len(),
534            target3,
535            split3
536        );
537
538        // With target 50, search range is 0-250, covers whole text
539        // Should find the last 。at position 38, return 39
540        assert_eq!(split3, 39);
541        assert_eq!(&text3[..split3], text3);
542    }
543
544    #[test]
545    fn test_find_safe_split_point_word_boundary() {
546        let text = "This is a very long sentence without any punctuation marks that goes on and on";
547        let split = find_safe_split_point(text, 40);
548        // Should split at a word boundary
549        assert!(split > 0);
550        assert!(text.chars().nth(split - 1).unwrap().is_whitespace() || split == text.len());
551    }
552
553    #[test]
554    fn test_find_safe_split_point_utf8_boundary() {
555        let text = "Hello 世界 World こんにちは Test";
556        let split = find_safe_split_point(text, 15);
557        // Should respect UTF-8 boundaries
558        assert!(text.is_char_boundary(split));
559    }
560
561    #[test]
562    fn test_find_safe_split_point_small_text() {
563        let text = "Short.";
564        let split = find_safe_split_point(text, 100);
565        assert_eq!(split, text.len());
566    }
567
568    #[test]
569    fn test_find_safe_split_point_exact_boundary() {
570        let text = "Exactly at boundary.";
571        let split = find_safe_split_point(text, text.len());
572        assert_eq!(split, text.len());
573    }
574
575    #[test]
576    fn test_find_safe_split_point_no_boundaries() {
577        let text = "NoSpacesOrPunctuationHereJustOneLongWord";
578        let split = find_safe_split_point(text, 20);
579        // Should still find a valid UTF-8 boundary
580        assert!(text.is_char_boundary(split));
581        assert!(split <= 20);
582    }
583}