1use anyhow::{Context, Result};
4use clap::Args;
5use std::path::PathBuf;
6
7#[derive(Debug, Args)]
9pub struct ProcessArgs {
10 #[arg(short, long, value_name = "FILE/PATTERN", required = true)]
12 pub input: Vec<String>,
13
14 #[arg(short, long, value_name = "FILE")]
16 pub output: Option<PathBuf>,
17
18 #[arg(short, long, value_enum, default_value = "text")]
20 pub format: OutputFormat,
21
22 #[arg(short, long, value_enum, conflicts_with = "language_config")]
25 pub language: Option<Language>,
26
27 #[arg(short = 'c', long, value_name = "FILE", conflicts_with = "language")]
30 pub language_config: Option<PathBuf>,
31
32 #[arg(long, requires = "language_config")]
35 pub language_code: Option<String>,
36
37 #[arg(short, long)]
39 pub parallel: bool,
40
41 #[arg(long, conflicts_with = "parallel")]
44 pub adaptive: bool,
45
46 #[arg(short = 't', long, value_name = "COUNT")]
48 pub threads: Option<usize>,
49
50 #[arg(long, value_name = "SIZE_KB")]
52 pub chunk_kb: Option<usize>,
53
54 #[arg(short, long)]
56 pub quiet: bool,
57
58 #[arg(short, long, action = clap::ArgAction::Count)]
60 pub verbose: u8,
61
62 #[arg(long)]
64 pub stream: bool,
65
66 #[arg(long, default_value = "10", requires = "stream")]
68 pub stream_chunk_mb: u64,
69}
70
71#[derive(Debug, Clone, Copy, clap::ValueEnum)]
73pub enum OutputFormat {
74 #[value(alias = "txt")]
76 Text,
77 Json,
79 #[value(alias = "md")]
81 Markdown,
82}
83
84#[derive(Debug, Clone, Copy, clap::ValueEnum)]
86pub enum Language {
87 #[value(alias = "en", alias = "eng")]
89 English,
90 #[value(alias = "ja", alias = "jpn")]
92 Japanese,
93}
94
95impl ProcessArgs {
96 pub fn execute(&self) -> Result<()> {
98 self.init_logging()?;
100
101 log::info!("Starting text processing");
102 log::debug!("Arguments: {self:?}");
103
104 let mut formatter: Box<dyn crate::output::OutputFormatter> = self.create_formatter()?;
106
107 let processor = self.create_processor()?;
109
110 if self.input.len() == 1 && self.input[0] == "-" {
112 log::info!("Reading from stdin");
113 self.process_stdin(&processor, &mut formatter)?;
114 } else {
115 let files = crate::input::resolve_patterns(&self.input)?;
117 log::info!("Found {} files to process", files.len());
118
119 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 let file_size_mb = crate::input::FileReader::file_size(file)? / (1024 * 1024);
128 let should_stream = self.stream || file_size_mb > 100; 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 let content = crate::input::FileReader::read_text(file)?;
140
141 let result = processor
143 .process(sakurs_core::Input::from_text(content.clone()))
144 .map_err(|e| anyhow::anyhow!("Processing failed: {}", e))?;
145
146 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 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 formatter.finish()?;
172 Ok(())
173 }
174
175 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 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 fn create_processor(&self) -> Result<sakurs_core::SentenceProcessor> {
234 use crate::language_source::LanguageSource;
235 use sakurs_core::{Config, SentenceProcessor};
236
237 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), (Some(_), Some(_)) => unreachable!(), };
247
248 log::info!("Using language source: {}", language_source.display_name());
249
250 match language_source {
252 LanguageSource::BuiltIn(lang) => {
253 let language_code = lang.code();
254
255 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 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 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 SentenceProcessor::with_language_config(config, &language)
291 .map_err(|e| anyhow::anyhow!("Failed to create processor: {}", e))
292 }
293 }
294 }
295
296 fn configure_builder(
298 &self,
299 builder: sakurs_core::ConfigBuilder,
300 ) -> Result<sakurs_core::ConfigBuilder> {
301 let mut builder = builder;
302
303 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); }
315
316 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 let chunk_size = chunk_kb * 1024;
323 builder = builder.chunk_size(chunk_size);
324 }
325
326 Ok(builder)
328 }
329
330 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 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 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 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 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#[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 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 let mut search_end = target.min(text.len());
420 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 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#[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 let text = "First. Second sentence here.";
465 let target = 10;
466 let split = find_safe_split_point(text, target);
467 assert_eq!(split, 28);
470 assert_eq!(&text[..split], text); let long_text = concat!(
474 "This is a sentence. ", "Another sentence. ", "Third sentence. ", "Fourth sentence. ", "Fifth sentence. ", "Sixth sentence. ", "Seventh sentence." );
482
483 let split = find_safe_split_point(long_text, 60);
485 println!("Long text len: {}, split: {}", long_text.len(), split);
486 assert_eq!(split, long_text.len()); 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 println!("Text3 period position: {}", text3.find('.').unwrap());
497 println!("Target3: {}, Split3: {}", target3, split3);
498 assert_eq!(split3, 70); }
502
503 #[test]
504 fn test_find_safe_split_point_japanese_sentence() {
505 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 assert_eq!(split, 12); let text2 = "これはとても長い日本語の文章で句読点がありません";
520 let target2 = 30;
521 let split2 = find_safe_split_point(text2, target2);
522
523 assert!(text2.is_char_boundary(split2));
525 assert!(split2 <= target2);
526
527 let text3 = "最初の文。二番目。三番目。";
529 let target3 = 50; 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 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 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 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 assert!(text.is_char_boundary(split));
581 assert!(split <= 20);
582 }
583}