pub mod passes;
pub mod rules;
pub mod tokens;
#[cfg(test)]
pub mod eval;
pub use rules::KNOWN_VARIANTS;
pub use passes::replace_word_matches;
use passes::PassStats;
use tokens::TokenStream;
#[derive(Debug, Clone)]
pub struct CleanConfig {
pub fix_variants: bool,
pub remove_fillers: bool,
pub dedupe_repetitions: bool,
pub normalize: bool,
pub user_fillers: Vec<String>,
}
impl Default for CleanConfig {
fn default() -> Self {
Self {
fix_variants: true,
remove_fillers: true,
dedupe_repetitions: true,
normalize: true,
user_fillers: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CleanResult {
pub text: String,
pub stats: PassStats,
}
pub fn clean_text(text: &str, vocab: &[String]) -> String {
clean_text_with(text, vocab, &CleanConfig::default()).text
}
pub fn clean_text_with(text: &str, vocab: &[String], config: &CleanConfig) -> CleanResult {
let trimmed = text.trim();
if trimmed.is_empty() {
return CleanResult {
text: text.to_string(),
stats: PassStats::default(),
};
}
let mut stats = PassStats::default();
let t = if config.fix_variants {
let (t, n) = passes::fix_variants(trimmed, vocab);
stats.variants_fixed = n;
t
} else {
trimmed.to_string()
};
let mut stream = TokenStream::tokenize(&t);
let mut trailing_filler = false;
if config.remove_fillers {
let mut iterations = 0usize;
loop {
let (s, trailing, n) = passes::remove_fillers(&stream, config);
stream = s;
if n > 0 {
stats.fillers_removed += n;
trailing_filler = trailing;
}
iterations += 1;
if n == 0 {
break;
}
if iterations >= 8 {
log::warn!(
"clean: teto de 8 iterações de muletas atingido — regras podem estar encadeando"
);
break;
}
}
}
if config.dedupe_repetitions {
let (s, n) = passes::dedupe_repetitions(&stream);
stream = s;
stats.repetitions_removed = n;
}
let text = if config.normalize {
passes::normalize(&stream, trailing_filler)
} else {
stream.render()
};
CleanResult { text, stats }
}
#[cfg(test)]
mod tests {
use super::*;
struct Rng(u64);
impl Rng {
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn below(&mut self, n: usize) -> usize {
(self.next_u64() % n as u64) as usize
}
fn pick(&mut self, items: &[char]) -> char {
items[self.below(items.len())]
}
}
const ALPHABET: &[char] = &[
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's',
't', 'u', 'v', 'x', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'L', 'M', 'N',
'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'X', 'Z', 'á', 'é', 'í', 'ó', 'ú', 'ã', 'õ', 'â',
'ê', 'ô', 'ç', 'Á', 'É', 'Í', 'Ó', 'Ú', 'Ã', 'Õ', 'Ç', 'İ', '0', '1', '2', '3', '4', '5',
'6', '7', '8', '9', ' ', ' ', ',', '.', '!', '?', ';', ':', '(', ')', '@', '/', '-', '_',
'🎉', '—', '\u{301}',
];
fn random_text(rng: &mut Rng, fillers: &[&str]) -> String {
let len = rng.below(90);
let mut s = String::with_capacity(len * 2);
for _ in 0..len {
if rng.below(10) == 0 {
s.push_str(fillers[rng.below(fillers.len())]);
} else {
s.push(rng.pick(ALPHABET));
}
}
s
}
fn content_words(s: &str) -> Vec<String> {
s.split(|c: char| !c.is_alphanumeric())
.filter(|w| !w.is_empty())
.map(|w| w.to_lowercase())
.collect()
}
const SAMPLE_VOCAB: &[&str] = &[
"GitHub", "Sam Altman", "Claude Code", "José", "São Paulo", "DeepSeek", "OpenAI",
];
const DECLARATIVE_TAGS: &[&str] = &["sabe", "entendeu", "viu", "tá"];
#[test]
fn property_never_panics_and_invents_nothing() {
let mut rng = Rng(0xC0FF_EE00_2026_0814);
let fillers: Vec<&str> = rules::DEFAULT_FILLERS
.iter()
.map(|r| r.word)
.chain(["né?", "sabe?", "tipo assim,"])
.collect();
let vocab_pool: Vec<String> = SAMPLE_VOCAB.iter().map(|s| s.to_string()).collect();
for _ in 0..3000 {
let mut vocab: Vec<String> = Vec::new();
for _ in 0..rng.below(4) {
vocab.push(vocab_pool[rng.below(vocab_pool.len())].clone());
}
let input = random_text(&mut rng, &fillers);
let result = clean_text_with(&input, &vocab, &CleanConfig::default());
let out = &result.text;
let input_words = content_words(&input);
let vocab_words: Vec<String> =
vocab.iter().flat_map(|v| content_words(v)).collect();
for w in content_words(out) {
let contained = input_words.iter().any(|iw| iw.contains(&w))
|| vocab_words.iter().any(|vw| vw.contains(&w));
assert!(
contained,
"output inventou '{w}' — input: {input:?} — output: {out:?}"
);
}
let again = clean_text_with(out, &vocab, &CleanConfig::default()).text;
assert_eq!(again, *out, "não-idempotente — input: {input:?}");
}
}
#[test]
fn property_terminal_punctuation_survives() {
let mut rng = Rng(0xDEAD_BEEF_2026_0001);
let fillers: Vec<&str> = rules::DEFAULT_FILLERS
.iter()
.map(|r| r.word)
.chain(["né", "sabe", "tá"])
.collect();
for _ in 0..3000 {
let input = random_text(&mut rng, &fillers);
let trimmed = input.trim_end();
let last = trimmed.chars().last();
if !matches!(last, Some('.') | Some('!') | Some('?')) {
continue;
}
let last_word = trimmed
.split_whitespace()
.last()
.map(|w| w.to_lowercase())
.unwrap_or_default();
if DECLARATIVE_TAGS.contains(&last_word.as_str()) {
continue; }
let out = clean_text(trimmed, &[]);
assert!(
out.ends_with(['.', '!', '?']),
"pontuação terminal perdida — input: {input:?} — output: {out:?}"
);
}
}
#[test]
fn long_dictation_cleans_well_under_budget() {
let base = "tipo, eu acho que a gente deveria melhorar o GitHub e o projeto do Sam Altman, né? ";
let mut text = String::new();
for _ in 0..40 {
text.push_str(base);
}
let vocab = vec!["GitHub".to_string(), "Sam Altman".to_string()];
let start = std::time::Instant::now();
let n = 50usize;
for _ in 0..n {
clean_text_with(&text, &vocab, &CleanConfig::default());
}
let elapsed = start.elapsed();
let per_run = elapsed / n as u32;
assert!(
elapsed.as_millis() < 5_000,
"limpeza lenta demais: {elapsed:?} para ~100k chars"
);
println!(
"clean em ~100k chars: {per_run:?} por passada (~{} chars)",
text.len()
);
}
}