mod blank;
mod cache;
mod config;
mod input;
mod nlp;
mod openrouter;
mod pages;
mod pdf;
mod postprocess;
mod prompt;
use anyhow::{Context, Result};
use clap::{CommandFactory, Parser, Subcommand};
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
use std::fs::File;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::Instant;
use crate::cache::{CacheKeyInput, cache_key, cache_path, load_cached, save_cached};
use crate::config::{get_api_key, save_api_key};
use crate::input::{InputSource, prepare_image_for_upload, resolve_input};
use crate::openrouter::OpenRouterClient;
use crate::pages::parse_pages;
use crate::pdf::{extract_page_text, page_count, render_page_to_png};
use crate::postprocess::{
PostProcessState, StitchAction, merge_stitched, stitch_decision, strip_boundary_artifacts,
};
use crate::prompt::{
Preset, build_system_prompt, build_user_text, build_user_text_from_text,
build_user_text_image_page, build_user_text_single_image,
};
#[derive(Parser)]
#[command(
name = "lmocr",
version,
about = "LLM-powered OCR for PDFs and images",
after_help = "Examples:\n lmocr doc.pdf -o out.md\n lmocr screenshot.png -o out.md\n lmocr photo.jpg -o out.md\n lmocr ./scanned_pages/ -o out.md\n lmocr book.pdf -p 10-12 --exclude headers,page-numbers\n",
help_template = "{about}\n\nUsage: {usage}\n\n{after-help}\n\n{all-args}\n"
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(value_name = "SOURCE")]
source: Option<PathBuf>,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(short, long)]
pages: Option<String>,
#[arg(short, long)]
instruction: Option<String>,
#[arg(long, value_enum, default_value_t = Preset::Markdown, value_name = "PRESET")]
preset: Preset,
#[arg(long, value_enum, value_delimiter = ',', num_args = 1.., value_name = "ELEMENTS")]
exclude: Vec<prompt::Exclude>,
#[arg(long)]
model: Option<String>,
#[arg(long, default_value_t = 300)]
quality: u32,
#[arg(short, long)]
verbose: bool,
#[arg(long)]
cache_dir: Option<PathBuf>,
#[arg(long)]
no_cache: bool,
#[arg(long, default_value_t = 3)]
max_retries: u32,
}
#[derive(Subcommand)]
enum Command {
Auth {
key: Option<String>,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
if cli.command.is_none() && cli.source.is_none() {
let mut cmd = Cli::command();
cmd.print_help()?;
println!();
return Ok(());
}
if let Some(Command::Auth { key }) = cli.command {
let api_key = match key {
Some(k) => k,
None => {
eprintln!("Enter OpenRouter API key:");
let mut input = String::new();
io::stdin().read_line(&mut input)?;
input.trim().to_string()
}
};
if api_key.is_empty() {
anyhow::bail!("API key is empty.");
}
save_api_key(&api_key)?;
eprintln!("Saved OpenRouter API key.");
return Ok(());
}
let source = cli
.source
.as_ref()
.context("Missing source file or folder")?;
let input = resolve_input(source)?;
match input {
InputSource::Pdf(ref path) => run_ocr_pdf(&cli, path).await,
InputSource::SingleImage { ref path, mime } => run_ocr_single_image(&cli, path, mime).await,
InputSource::ImageFolder { ref images } => run_ocr_image_folder(&cli, images).await,
}
}
async fn run_ocr_pdf(cli: &Cli, source: &Path) -> Result<()> {
let api_key = get_api_key().context("OpenRouter API key not configured")?;
let client = OpenRouterClient::new(api_key)?;
let total_pages = page_count(source)?;
let pages = parse_pages(cli.pages.as_deref(), total_pages)?;
let excludes = cli.exclude.clone();
let system_prompt =
build_system_prompt(cli.preset, &excludes, cli.instruction.as_deref(), false);
let model = cli
.model
.clone()
.unwrap_or_else(|| openrouter::DEFAULT_MODEL.to_string());
let cache_root = cache_path(cli.cache_dir.as_deref());
let mut out: Box<dyn Write> = match &cli.output {
Some(path) => {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
Box::new(File::create(path)?)
}
None => Box::new(io::stdout()),
};
let show_progress = cli.output.is_some() && !cli.verbose;
let progress = if show_progress {
let pb = ProgressBar::new(pages.len() as u64);
pb.set_draw_target(ProgressDrawTarget::stderr());
pb.set_style(
ProgressStyle::with_template(
"[{elapsed_precise}<{eta_precise}] {bar:40.cyan/blue} {pos}/{len} {msg}",
)
.unwrap(),
);
pb
} else {
ProgressBar::hidden()
};
let mut total_prompt_tokens = 0u64;
let mut total_completion_tokens = 0u64;
let mut total_tokens = 0u64;
let mut previous_tail: Option<String> = None;
let mut pending_output: Option<String> = None;
let mut wrote_any = false;
let mut post_state = PostProcessState::new();
let temp_dir = tempfile::TempDir::new()?;
for page in pages.iter() {
progress.set_message(format!("page {}", page));
let context_tail = previous_tail.as_deref();
let extracted_text = match extract_page_text(source, *page) {
Ok(text) => text,
Err(err) => {
if cli.verbose {
eprintln!("page {}: pdftotext failed: {}", page, err);
}
None
}
};
let use_text = extracted_text
.as_deref()
.map(should_use_extracted_text)
.unwrap_or(false);
let input_mode = if use_text { "text" } else { "image" };
let key = cache_key(&CacheKeyInput {
source,
page: *page,
total_pages,
dpi: cli.quality,
model: &model,
preset: cli.preset,
excludes: &excludes,
instruction: cli.instruction.as_deref(),
prev_tail: context_tail,
input_mode,
})?;
let cache_file = cache_root.join(format!("{key}.md"));
let mut output_text = if !cli.no_cache {
load_cached(&cache_file)
} else {
None
};
if output_text.is_some() && cli.verbose {
eprintln!("page {}: cache hit", page);
}
if output_text.is_none() {
if use_text {
if cli.verbose {
eprintln!("page {}: using extracted text", page);
}
let extracted = extracted_text.as_deref().unwrap_or_default();
if cli.verbose {
eprintln!(
"page {}: extracted text chars {}, alnum {}",
page,
extracted.len(),
count_alnum(extracted)
);
}
let user_text =
build_user_text_from_text(*page, total_pages, context_tail, extracted);
let total_attempts = cli.max_retries + 1;
for attempt in 0..total_attempts {
let started = Instant::now();
match client
.ocr_page(&model, &system_prompt, &user_text, None, "image/png")
.await
{
Ok(result) => {
let elapsed = started.elapsed();
total_prompt_tokens += result.usage.prompt_tokens;
total_completion_tokens += result.usage.completion_tokens;
total_tokens += result.usage.total_tokens;
if cli.verbose {
eprintln!(
"page {}: {}ms, prompt {}, completion {}, total {}",
page,
elapsed.as_millis(),
result.usage.prompt_tokens,
result.usage.completion_tokens,
result.usage.total_tokens
);
}
output_text = Some(result.content);
break;
}
Err(err) => {
let attempt_no = attempt + 1;
if attempt < cli.max_retries {
eprintln!(
"page {} attempt {}/{} failed: {}",
page, attempt_no, total_attempts, err
);
let backoff_ms = 500u64.saturating_mul((attempt_no).min(6) as u64);
tokio::time::sleep(std::time::Duration::from_millis(backoff_ms))
.await;
continue;
}
return Err(err).context(format!("Failed page {}", page));
}
}
}
if let Some(ref output) = output_text
&& cli.preset == Preset::Markdown
{
let coverage = text_coverage_ratio(output, extracted);
if cli.verbose {
eprintln!("page {}: extracted-text coverage {:.2}", page, coverage);
}
if coverage < 0.9 {
if cli.verbose {
eprintln!(
"page {}: extracted-text rewrite dropped content (coverage {:.2}); using raw extracted text",
page, coverage
);
}
output_text = Some(extracted.to_string());
}
}
} else {
let user_text = build_user_text(*page, total_pages, context_tail);
let total_attempts = cli.max_retries + 1;
let mut last_err = None;
let qualities = render_quality_fallbacks(cli.quality);
for (idx, quality) in qualities.iter().copied().enumerate() {
let image_path = render_page_to_png(source, *page, quality, &temp_dir)?;
let image_bytes = std::fs::read(&image_path)?;
let blank_page = match blank::is_blank_image(&image_bytes) {
Ok(is_blank) => is_blank,
Err(err) => {
if cli.verbose {
eprintln!("page {}: blank-page check failed: {}", page, err);
}
false
}
};
if blank_page {
if cli.verbose {
eprintln!("page {}: detected blank page, skipping OCR", page);
}
output_text = Some(String::new());
break;
}
for attempt in 0..total_attempts {
let started = Instant::now();
match client
.ocr_page(
&model,
&system_prompt,
&user_text,
Some(&image_bytes),
"image/png",
)
.await
{
Ok(result) => {
let elapsed = started.elapsed();
total_prompt_tokens += result.usage.prompt_tokens;
total_completion_tokens += result.usage.completion_tokens;
total_tokens += result.usage.total_tokens;
if cli.verbose {
eprintln!(
"page {}: {}ms, prompt {}, completion {}, total {}",
page,
elapsed.as_millis(),
result.usage.prompt_tokens,
result.usage.completion_tokens,
result.usage.total_tokens
);
}
output_text = Some(result.content);
last_err = None;
break;
}
Err(err) => {
let attempt_no = attempt + 1;
if attempt < cli.max_retries {
eprintln!(
"page {} attempt {}/{} failed: {}",
page, attempt_no, total_attempts, err
);
let backoff_ms =
500u64.saturating_mul((attempt_no).min(6) as u64);
tokio::time::sleep(std::time::Duration::from_millis(
backoff_ms,
))
.await;
continue;
}
last_err = Some(err);
}
}
break;
}
if output_text.is_some() {
break;
}
if cli.verbose
&& idx + 1 < qualities.len()
&& let Some(next_quality) = qualities.get(idx + 1)
{
eprintln!(
"page {}: retrying with lower render quality {} dpi after failures at {} dpi",
page, next_quality, quality
);
}
}
if output_text.is_none() {
if let Some(err) = last_err {
return Err(err).context(format!("Failed page {}", page));
}
anyhow::bail!("Failed page {}: no output produced", page);
}
}
if let Some(ref text) = output_text
&& !cli.no_cache
{
save_cached(&cache_file, text)?;
}
}
let output_text = output_text.context(format!("Missing output for page {}", page))?;
let mut cleaned = post_state.process(output_text.trim_end(), &excludes);
if let Some(prev_pending) = pending_output.as_ref() {
cleaned = strip_boundary_artifacts(prev_pending, &cleaned, &excludes);
}
previous_tail = if cleaned.trim().is_empty() {
None
} else {
Some(prompt::tail_for_context(&cleaned))
};
if cleaned.trim().is_empty() {
if let Some(prev) = pending_output.take() {
write_page(&mut out, &prev, &mut wrote_any)?;
}
progress.inc(1);
continue;
}
match pending_output.take() {
None => pending_output = Some(cleaned),
Some(prev) => {
let decision = stitch_decision(&prev, &cleaned);
if decision == StitchAction::None {
write_page(&mut out, &prev, &mut wrote_any)?;
pending_output = Some(cleaned);
} else {
let merged = merge_stitched(&prev, &cleaned, decision);
pending_output = Some(merged);
}
}
}
progress.inc(1);
}
progress.finish_and_clear();
if let Some(pending) = pending_output {
write_page(&mut out, &pending, &mut wrote_any)?;
}
if cli.verbose {
eprintln!(
"Processed {} pages. Tokens: prompt {}, completion {}, total {}.",
pages.len(),
total_prompt_tokens,
total_completion_tokens,
total_tokens
);
}
Ok(())
}
fn ensure_empty_output_file(output: Option<&PathBuf>) -> Result<()> {
if let Some(path) = output {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
File::create(path)?;
}
Ok(())
}
async fn run_ocr_single_image(
cli: &Cli,
image_path: &Path,
mime: input::ImageMimeType,
) -> Result<()> {
let api_key = get_api_key().context("OpenRouter API key not configured")?;
let client = OpenRouterClient::new(api_key)?;
let excludes = cli.exclude.clone();
let system_prompt =
build_system_prompt(cli.preset, &excludes, cli.instruction.as_deref(), true);
let model = cli
.model
.clone()
.unwrap_or_else(|| openrouter::DEFAULT_MODEL.to_string());
let cache_root = cache_path(cli.cache_dir.as_deref());
if cli.verbose && cli.quality != 300 {
eprintln!("Note: --quality has no effect on image inputs");
}
let image_bytes = std::fs::read(image_path)
.with_context(|| format!("Failed to read image: {}", image_path.display()))?;
let blank = match blank::is_blank_image(&image_bytes) {
Ok(is_blank) => is_blank,
Err(err) => {
if cli.verbose {
eprintln!("blank-image check failed: {}", err);
}
false
}
};
if blank {
if cli.verbose {
eprintln!("Detected blank image, skipping OCR");
}
ensure_empty_output_file(cli.output.as_ref())?;
return Ok(());
}
let key = cache_key(&CacheKeyInput {
source: image_path,
page: 1,
total_pages: 1,
dpi: 0,
model: &model,
preset: cli.preset,
excludes: &excludes,
instruction: cli.instruction.as_deref(),
prev_tail: None,
input_mode: "single_image",
})?;
let cache_file = cache_root.join(format!("{key}.md"));
let mut output_text = if !cli.no_cache {
load_cached(&cache_file)
} else {
None
};
if output_text.is_some() && cli.verbose {
eprintln!("cache hit");
}
if output_text.is_none() {
let user_text = build_user_text_single_image();
let upload = prepare_image_for_upload(&image_bytes, mime)?;
let total_attempts = cli.max_retries + 1;
for attempt in 0..total_attempts {
let started = Instant::now();
match client
.ocr_page(
&model,
&system_prompt,
&user_text,
Some(upload.bytes.as_ref()),
upload.mime_type,
)
.await
{
Ok(result) => {
let elapsed = started.elapsed();
if cli.verbose {
eprintln!(
"{}ms, prompt {}, completion {}, total {}",
elapsed.as_millis(),
result.usage.prompt_tokens,
result.usage.completion_tokens,
result.usage.total_tokens
);
}
output_text = Some(result.content);
break;
}
Err(err) => {
let attempt_no = attempt + 1;
if attempt < cli.max_retries {
eprintln!("attempt {}/{} failed: {}", attempt_no, total_attempts, err);
let backoff_ms = 500u64.saturating_mul((attempt_no).min(6) as u64);
tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
continue;
}
return Err(err).context("Failed to process image");
}
}
}
if let Some(ref text) = output_text
&& !cli.no_cache
{
save_cached(&cache_file, text)?;
}
}
let mut out: Box<dyn Write> = match &cli.output {
Some(path) => {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
Box::new(File::create(path)?)
}
None => Box::new(io::stdout()),
};
if let Some(text) = output_text {
let trimmed = text.trim();
if !trimmed.is_empty() {
out.write_all(trimmed.as_bytes())?;
writeln!(out)?;
}
}
Ok(())
}
async fn run_ocr_image_folder(cli: &Cli, images: &[(PathBuf, input::ImageMimeType)]) -> Result<()> {
let api_key = get_api_key().context("OpenRouter API key not configured")?;
let client = OpenRouterClient::new(api_key)?;
let total_pages = images.len();
let pages = parse_pages(cli.pages.as_deref(), total_pages)?;
let excludes = cli.exclude.clone();
let system_prompt =
build_system_prompt(cli.preset, &excludes, cli.instruction.as_deref(), false);
let model = cli
.model
.clone()
.unwrap_or_else(|| openrouter::DEFAULT_MODEL.to_string());
let cache_root = cache_path(cli.cache_dir.as_deref());
if cli.verbose && cli.quality != 300 {
eprintln!("Note: --quality has no effect on image inputs");
}
let mut out: Box<dyn Write> = match &cli.output {
Some(path) => {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
Box::new(File::create(path)?)
}
None => Box::new(io::stdout()),
};
let show_progress = cli.output.is_some() && !cli.verbose;
let progress = if show_progress {
let pb = ProgressBar::new(pages.len() as u64);
pb.set_draw_target(ProgressDrawTarget::stderr());
pb.set_style(
ProgressStyle::with_template(
"[{elapsed_precise}<{eta_precise}] {bar:40.cyan/blue} {pos}/{len} {msg}",
)
.unwrap(),
);
pb
} else {
ProgressBar::hidden()
};
let mut total_prompt_tokens = 0u64;
let mut total_completion_tokens = 0u64;
let mut total_tokens = 0u64;
let mut previous_tail: Option<String> = None;
let mut pending_output: Option<String> = None;
let mut wrote_any = false;
let mut post_state = PostProcessState::new();
for page in pages.iter() {
let (image_path, mime) = &images[*page - 1];
progress.set_message(format!("image {}", page));
let context_tail = previous_tail.as_deref();
let key = cache_key(&CacheKeyInput {
source: image_path,
page: *page,
total_pages,
dpi: 0,
model: &model,
preset: cli.preset,
excludes: &excludes,
instruction: cli.instruction.as_deref(),
prev_tail: context_tail,
input_mode: "image_folder_page",
})?;
let cache_file = cache_root.join(format!("{key}.md"));
let mut output_text = if !cli.no_cache {
load_cached(&cache_file)
} else {
None
};
if output_text.is_some() && cli.verbose {
eprintln!("image {}: cache hit", page);
}
if output_text.is_none() {
let image_bytes = std::fs::read(image_path)
.with_context(|| format!("Failed to read image: {}", image_path.display()))?;
let blank = match blank::is_blank_image(&image_bytes) {
Ok(is_blank) => is_blank,
Err(err) => {
if cli.verbose {
eprintln!("image {}: blank check failed: {}", page, err);
}
false
}
};
if blank {
if cli.verbose {
eprintln!("image {}: detected blank image, skipping OCR", page);
}
output_text = Some(String::new());
} else {
let user_text = build_user_text_image_page(*page, total_pages, context_tail);
let upload = prepare_image_for_upload(&image_bytes, *mime)?;
let total_attempts = cli.max_retries + 1;
for attempt in 0..total_attempts {
let started = Instant::now();
match client
.ocr_page(
&model,
&system_prompt,
&user_text,
Some(upload.bytes.as_ref()),
upload.mime_type,
)
.await
{
Ok(result) => {
let elapsed = started.elapsed();
total_prompt_tokens += result.usage.prompt_tokens;
total_completion_tokens += result.usage.completion_tokens;
total_tokens += result.usage.total_tokens;
if cli.verbose {
eprintln!(
"image {}: {}ms, prompt {}, completion {}, total {}",
page,
elapsed.as_millis(),
result.usage.prompt_tokens,
result.usage.completion_tokens,
result.usage.total_tokens
);
}
output_text = Some(result.content);
break;
}
Err(err) => {
let attempt_no = attempt + 1;
if attempt < cli.max_retries {
eprintln!(
"image {} attempt {}/{} failed: {}",
page, attempt_no, total_attempts, err
);
let backoff_ms = 500u64.saturating_mul((attempt_no).min(6) as u64);
tokio::time::sleep(std::time::Duration::from_millis(backoff_ms))
.await;
continue;
}
return Err(err).context(format!("Failed image {}", page));
}
}
}
}
if let Some(ref text) = output_text
&& !cli.no_cache
{
save_cached(&cache_file, text)?;
}
}
let output_text = output_text.context(format!("Missing output for image {}", page))?;
let mut cleaned = post_state.process(output_text.trim_end(), &excludes);
if let Some(prev_pending) = pending_output.as_ref() {
cleaned = strip_boundary_artifacts(prev_pending, &cleaned, &excludes);
}
previous_tail = if cleaned.trim().is_empty() {
None
} else {
Some(prompt::tail_for_context(&cleaned))
};
if cleaned.trim().is_empty() {
if let Some(prev) = pending_output.take() {
write_page(&mut out, &prev, &mut wrote_any)?;
}
progress.inc(1);
continue;
}
match pending_output.take() {
None => pending_output = Some(cleaned),
Some(prev) => {
let decision = stitch_decision(&prev, &cleaned);
if decision == StitchAction::None {
write_page(&mut out, &prev, &mut wrote_any)?;
pending_output = Some(cleaned);
} else {
let merged = merge_stitched(&prev, &cleaned, decision);
pending_output = Some(merged);
}
}
}
progress.inc(1);
}
progress.finish_and_clear();
if let Some(pending) = pending_output {
write_page(&mut out, &pending, &mut wrote_any)?;
}
if cli.verbose {
eprintln!(
"Processed {} images. Tokens: prompt {}, completion {}, total {}.",
pages.len(),
total_prompt_tokens,
total_completion_tokens,
total_tokens
);
}
Ok(())
}
fn write_page(out: &mut dyn Write, text: &str, wrote_any: &mut bool) -> io::Result<()> {
if *wrote_any {
writeln!(out)?;
}
let trimmed = text.trim_end();
out.write_all(trimmed.as_bytes())?;
writeln!(out)?;
*wrote_any = true;
Ok(())
}
fn should_use_extracted_text(text: &str) -> bool {
let mut letters = 0usize;
let mut non_ws = 0usize;
for ch in text.chars() {
if ch.is_whitespace() {
continue;
}
non_ws += 1;
if ch.is_ascii_alphabetic() {
letters += 1;
}
}
if non_ws == 0 {
return false;
}
letters >= 80 && (letters as f32 / non_ws as f32) > 0.4
}
fn text_coverage_ratio(output: &str, input: &str) -> f32 {
let out_count = count_alnum(output);
let in_count = count_alnum(input);
if in_count == 0 {
return 1.0;
}
out_count as f32 / in_count as f32
}
fn count_alnum(text: &str) -> usize {
text.chars().filter(|ch| ch.is_ascii_alphanumeric()).count()
}
fn render_quality_fallbacks(base_quality: u32) -> Vec<u32> {
let mut qualities = vec![base_quality];
for candidate in [240u32, 200, 180, 150] {
if candidate < base_quality {
qualities.push(candidate);
}
}
qualities
}
#[cfg(test)]
mod tests {
use super::{ensure_empty_output_file, render_quality_fallbacks};
use std::fs;
use tempfile::TempDir;
#[test]
fn quality_fallbacks_descend_from_default() {
assert_eq!(render_quality_fallbacks(300), vec![300, 240, 200, 180, 150]);
}
#[test]
fn quality_fallbacks_for_lower_requested_dpi() {
assert_eq!(render_quality_fallbacks(200), vec![200, 180, 150]);
assert_eq!(render_quality_fallbacks(150), vec![150]);
}
#[test]
fn ensure_empty_output_file_truncates_existing_file() {
let dir = TempDir::new().unwrap();
let output = dir.path().join("nested/out.md");
fs::create_dir_all(output.parent().unwrap()).unwrap();
fs::write(&output, "stale content").unwrap();
ensure_empty_output_file(Some(&output)).unwrap();
assert_eq!(fs::read_to_string(&output).unwrap(), "");
}
use super::should_use_extracted_text;
#[test]
fn extracted_text_enough_letters() {
let text = "a".repeat(80); assert!(should_use_extracted_text(&text));
}
#[test]
fn extracted_text_too_few_letters() {
let text = "a".repeat(79); assert!(!should_use_extracted_text(&text));
}
#[test]
fn extracted_text_low_ratio() {
let text = format!("{}{}", "a".repeat(80), "1".repeat(120));
assert!(!should_use_extracted_text(&text));
}
#[test]
fn extracted_text_above_ratio() {
let text = format!("{}{}", "a".repeat(80), "1".repeat(119));
assert!(should_use_extracted_text(&text));
}
#[test]
fn extracted_text_empty() {
assert!(!should_use_extracted_text(""));
}
#[test]
fn extracted_text_whitespace_only() {
assert!(!should_use_extracted_text(" \n\t "));
}
#[test]
fn extracted_text_all_numeric() {
let text = "1".repeat(200);
assert!(!should_use_extracted_text(&text)); }
use super::text_coverage_ratio;
#[test]
fn coverage_ratio_normal() {
let ratio = text_coverage_ratio("hello world", "hello world");
assert!((ratio - 1.0).abs() < 0.01);
}
#[test]
fn coverage_ratio_empty_input() {
assert_eq!(text_coverage_ratio("anything", ""), 1.0);
}
#[test]
fn coverage_ratio_empty_output() {
assert!(text_coverage_ratio("", "hello") < 0.01);
}
#[test]
fn coverage_ratio_partial() {
let ratio = text_coverage_ratio("hel", "hello");
assert!((ratio - 0.6).abs() < 0.01);
}
#[test]
fn coverage_ratio_output_longer() {
let ratio = text_coverage_ratio("hello world", "hello");
assert!(ratio > 1.0); }
}