use clap::{Arg, ArgAction, Command};
use serde_json::{json, Value};
use std::env;
use std::io::{self, Read};
use std::num::NonZeroU32;
use std::time::{Duration, Instant};
use obadh_engine::engine::{Token, TokenType, Transliterator};
const VERSION: &str = env!("CARGO_PKG_VERSION");
fn main() -> Result<(), Box<dyn std::error::Error>> {
let matches = cli().get_matches();
let debug_mode = matches.get_flag("debug");
let verbose_mode = matches.get_flag("verbose");
let pretty_print = matches.get_flag("pretty");
let benchmark_iterations = matches.get_one::<NonZeroU32>("benchmark").copied();
let input = if let Some(text) = matches.get_one::<String>("INPUT") {
text.clone()
} else {
let mut buffer = String::new();
let bytes_read = io::stdin().read_to_string(&mut buffer)?;
if bytes_read == 0 {
let _ = cli().print_help();
println!();
return Ok(());
}
buffer
};
let transliterator = Transliterator::new();
if let Some(iterations) = benchmark_iterations {
benchmark(
&transliterator,
&input,
iterations,
debug_mode || verbose_mode,
pretty_print,
)
} else if debug_mode || verbose_mode {
process_json_output(&transliterator, &input, verbose_mode, pretty_print)
} else {
let result = transliterator.transliterate(&input);
println!("{}", result);
Ok(())
}
}
fn cli() -> Command {
Command::new("obadh")
.version(VERSION)
.about("A deterministic Roman-to-Bengali transliteration engine")
.arg(
Arg::new("INPUT")
.help("Input text to transliterate")
.index(1),
)
.arg(
Arg::new("debug")
.short('d')
.long("debug")
.help("Output detailed information in JSON format")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("verbose")
.short('v')
.long("verbose")
.help("Output more detailed information in JSON format")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("benchmark")
.short('b')
.long("benchmark")
.help("Run benchmark with N positive iterations")
.num_args(0..=1)
.default_missing_value("1")
.value_parser(parse_benchmark_iterations),
)
.arg(
Arg::new("pretty")
.short('p')
.long("pretty")
.help("Pretty-print the JSON output (only used with --debug or --verbose)")
.action(ArgAction::SetTrue),
)
}
fn parse_benchmark_iterations(value: &str) -> Result<NonZeroU32, String> {
let iterations = value
.parse::<u32>()
.map_err(|error| format!("invalid benchmark iteration count: {error}"))?;
NonZeroU32::new(iterations)
.ok_or_else(|| String::from("benchmark iterations must be greater than zero"))
}
fn process_json_output(
transliterator: &Transliterator,
input: &str,
verbose: bool,
pretty_print: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let profile = profile_transliteration(transliterator, input);
let mut output_json = json!({
"input": input,
"output": profile.output,
"performance": {
"total_ms": format_duration(profile.total_duration()),
"sanitize_ms": format_duration(profile.sanitize_duration),
"tokenize_ms": format_duration(profile.tokenize_duration),
"transliterate_ms": format_duration(profile.transliterate_duration),
}
});
if verbose {
if let Value::Object(ref mut map) = output_json {
let token_analysis = profile
.tokens
.iter()
.enumerate()
.map(|(index, token)| {
let mut token_json = json!({
"content": token.content,
"type": format!("{:?}", token.token_type),
"position": token.position,
"transliterated": transliterator
.transliterate_token_at(&profile.tokens, index)
});
if token.token_type == TokenType::Word {
let phonetic_units = transliterator.tokenize_phonetic(&token.content);
let units_json = phonetic_units
.iter()
.map(|unit| {
json!({
"text": unit.text,
"type": format!("{:?}", unit.unit_type),
"position": unit.position
})
})
.collect::<Vec<_>>();
if let Value::Object(ref mut token_map) = token_json {
token_map.insert("phonetic_units".to_string(), json!(units_json));
}
}
token_json
})
.collect::<Vec<_>>();
map.insert("token_analysis".to_string(), json!(token_analysis));
}
}
if pretty_print {
println!("{}", serde_json::to_string_pretty(&output_json)?);
} else {
println!("{}", serde_json::to_string(&output_json)?);
}
Ok(())
}
fn format_duration(duration: Duration) -> f64 {
duration.as_secs_f64() * 1000.0
}
fn benchmark(
transliterator: &Transliterator,
input: &str,
iterations: NonZeroU32,
json_output: bool,
pretty_print: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let iteration_count = iterations.get();
let mut total_duration = Duration::new(0, 0);
let mut sanitize_duration = Duration::new(0, 0);
let mut tokenize_duration = Duration::new(0, 0);
let mut transliterate_duration = Duration::new(0, 0);
for _ in 0..iteration_count {
let profile = profile_transliteration(transliterator, input);
sanitize_duration += profile.sanitize_duration;
tokenize_duration += profile.tokenize_duration;
transliterate_duration += profile.transliterate_duration;
total_duration += profile.total_duration();
}
let avg_total = total_duration / iteration_count;
let avg_sanitize = sanitize_duration / iteration_count;
let avg_tokenize = tokenize_duration / iteration_count;
let avg_transliterate = transliterate_duration / iteration_count;
let transliterated = transliterator.transliterate(input);
if json_output {
let benchmark_json = json!({
"input": input,
"output": transliterated,
"benchmark": {
"iterations": iteration_count,
"avg_total_ms": format_duration(avg_total),
"avg_sanitize_ms": format_duration(avg_sanitize),
"avg_tokenize_ms": format_duration(avg_tokenize),
"avg_transliterate_ms": format_duration(avg_transliterate),
"total_run_time_ms": format_duration(total_duration),
}
});
if pretty_print {
println!("{}", serde_json::to_string_pretty(&benchmark_json)?);
} else {
println!("{}", serde_json::to_string(&benchmark_json)?);
}
} else {
println!("Translation: {}", transliterated);
println!("Benchmark results ({} iterations):", iteration_count);
println!(" Average total time: {:.4} ms", format_duration(avg_total));
println!(
" Average sanitize time: {:.4} ms",
format_duration(avg_sanitize)
);
println!(
" Average tokenize time: {:.4} ms",
format_duration(avg_tokenize)
);
println!(
" Average transliterate time: {:.4} ms",
format_duration(avg_transliterate)
);
println!(
" Total run time: {:.4} ms",
format_duration(total_duration)
);
}
Ok(())
}
struct TransliterationProfile {
output: String,
tokens: Vec<Token>,
sanitize_duration: Duration,
tokenize_duration: Duration,
transliterate_duration: Duration,
}
impl TransliterationProfile {
fn total_duration(&self) -> Duration {
self.sanitize_duration + self.tokenize_duration + self.transliterate_duration
}
}
fn profile_transliteration(transliterator: &Transliterator, input: &str) -> TransliterationProfile {
let sanitize_start = Instant::now();
let sanitized = transliterator.sanitize(input);
let sanitize_duration = sanitize_start.elapsed();
match sanitized {
Ok(sanitized) => {
let tokenize_start = Instant::now();
let tokens = transliterator.tokenize(&sanitized);
let tokenize_duration = tokenize_start.elapsed();
let transliterate_start = Instant::now();
let output = transliterator.transliterate_tokens(&tokens);
let transliterate_duration = transliterate_start.elapsed();
TransliterationProfile {
output,
tokens,
sanitize_duration,
tokenize_duration,
transliterate_duration,
}
}
Err(_) => TransliterationProfile {
output: input.to_string(),
tokens: Vec::new(),
sanitize_duration,
tokenize_duration: Duration::ZERO,
transliterate_duration: Duration::ZERO,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profile_total_is_the_sum_of_current_iteration_phases() {
let profile = TransliterationProfile {
output: String::new(),
tokens: Vec::new(),
sanitize_duration: Duration::from_micros(3),
tokenize_duration: Duration::from_micros(5),
transliterate_duration: Duration::from_micros(7),
};
assert_eq!(profile.total_duration(), Duration::from_micros(15));
}
#[test]
fn profiled_output_matches_normal_transliteration() {
let transliterator = Transliterator::new();
let input = "strI bhokt prokash korchhi 12.34";
let profile = profile_transliteration(&transliterator, input);
assert_eq!(profile.output, transliterator.transliterate(input));
assert!(!profile.tokens.is_empty());
}
#[test]
fn benchmark_iteration_parser_rejects_zero() {
assert_eq!(parse_benchmark_iterations("1").map(NonZeroU32::get), Ok(1));
assert_eq!(
parse_benchmark_iterations("0").unwrap_err(),
"benchmark iterations must be greater than zero"
);
}
#[test]
fn cli_benchmark_flag_defaults_to_one_iteration() {
let matches = cli()
.try_get_matches_from(["obadh", "--benchmark"])
.expect("benchmark flag without value should use default_missing_value");
assert_eq!(
matches
.get_one::<NonZeroU32>("benchmark")
.copied()
.map(NonZeroU32::get),
Some(1)
);
}
#[test]
fn cli_benchmark_flag_rejects_zero_iterations() {
let error = cli()
.try_get_matches_from(["obadh", "--benchmark", "0", "ami"])
.expect_err("zero iterations should be rejected");
assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation);
}
}