Capstone: una herramienta CLI de frecuencia de palabras
Todas las ideas de este curso — ownership, Result, HashMap, iteradores — convergen en un programa pequeño y real que lee texto, cuenta palabras y reporta las que más importan.
Esta es la última lección del curso, y en lugar de presentar un concepto más, te pide que construyas algo — una pequeña herramienta de línea de comandos que lee texto, cuenta cuántas veces aparece cada palabra e imprime las palabras más usadas. Esta forma exacta, 'leer datos, transformarlos, resumirlos', subyace a una enorme cantidad de herramientas CLI reales: analizadores de logs, `wc`, correctores ortográficos, incluso el paso de ranking detrás de un motor de búsqueda. Al final de esta lección habrás escrito un programa completo y compilable que se apoya en casi todo lo que aprendiste — ownership, `Result`, `HashMap` y las cadenas de iteradores, principalmente — y verás exactamente dónde se gana su lugar cada uno, en lugar de que te pidan confiar en que algún día lo hará.
Una nota práctica antes del código: una CLI real leería su entrada desde un archivo (`std::fs::read_to_string`) o desde standard input recibido por pipe (`std::io::stdin().lines()`), pero esta lección usa un string de ejemplo escrito directamente en el código, para que cada ejemplo aquí compile y corra completamente por sí solo, sin nada externo que conectar. Eso no simplifica la parte interesante — la parte interesante es todo lo que pasa *después* de tener un `String`, y esa lógica es idéntica sin importar si el string vino de un literal fijo, un archivo o una petición de red. Cambia la fuente, conserva el resto, y tienes una herramienta real; ese es todo el sentido de separar 'de dónde vino este String' de 'qué hago con este String' desde el principio.
La primera etapa es convertir el texto crudo en una lista limpia de palabras. `split_whitespace()` te lleva casi todo el camino, pero no es suficiente: "fox.", "fox," y "Fox" contarían como palabras distintas aunque una persona leyendo el texto las llamaría la misma palabra. Así que a cada palabra también hay que quitarle la puntuación y uniformar sus mayúsculas antes de que sea una unidad justa para contar. Este es exactamente el estilo de encadenar iteradores que viste antes en este curso — `.map()` para transformar cada palabra, `.filter()` para descartar lo que quede vacío tras quitar la puntuación, `.collect()` para materializar el resultado — elegido en vez de un loop escrito a mano porque cada paso nombra exactamente una transformación, en el orden en que ocurre.
La segunda etapa es contar, y `HashMap<String, u32>` es la estructura natural para eso: una palabra se asocia con cuántas veces se ha visto. La forma idiomática de actualizar un conteo es la entry API — `*counts.entry(word).or_insert(0) += 1` — que maneja ambos casos ('nunca vi esta palabra' y 'ya vi esta palabra antes') en una sola expresión en lugar de un par `if let`/`else`. Fíjate en que construir la lista de palabras y construir el mapa de conteos son dos pasos separados que operan sobre `String`s con ownership propio en lugar de slices `&str` prestados; el `HashMap` necesita poseer sus claves porque va a sobrevivir a cualquier referencia individual hacia el texto original, que es el ownership de las primeras lecciones de este curso apareciendo de nuevo, ahora en el diseño de una estructura de datos y no solo en firmas de funciones.
La tercera etapa es ordenar, y ahí sale a la luz algo que vale la pena saber sobre `HashMap`: no tiene un orden de iteración definido, y ese orden puede incluso cambiar entre ejecuciones del mismo programa. Para ordenar las palabras por frecuencia, primero conviertes el mapa en un `Vec<(String, u32)>` con `.into_iter().collect()`, lo cual te da algo con un orden real y controlable, y luego llamas a `.sort_by()` con un comparador. Ordenar solo por conteo tampoco es del todo determinista, porque los empates (dos palabras que aparecen el mismo número de veces) saldrían en el orden que el `HashMap` haya producido por casualidad — así que el comparador aquí desempata alfabéticamente, un detalle pequeño pero es la diferencia entre una herramienta contra cuya salida puedes escribir un test y una contra la que no puedes.
La última decisión de diseño es qué devuelve la función de lógica principal. Sería más simple que `top_n_words` devolviera solo un `Vec<(String, u32)>` y dejara que una entrada vacía produjera silenciosamente un vector vacío — pero eso esconde una distinción real: 'legítimamente no había palabras interesantes' y 'algo estuvo mal con la entrada' son situaciones distintas, y quien llama a la función podría querer reaccionar de forma diferente a cada una. Devolver `Result<Vec<(String, u32)>, EmptyInputError>` hace esa distinción explícita en la propia firma de tipos, igual que las funciones que devuelven `Result` de lecciones anteriores de este curso. `main` es entonces el único lugar que realmente decide qué hacer ante un fallo — imprimir un error y salir con elegancia mediante un `match`, en lugar de dejar que todo el programa entre en panic por lo que podría ser simplemente un archivo vacío.
Vale la pena notar lo que `main` *no* contiene: ni `tokenize`, ni `count_words`, ni `top_n_words` leen un archivo, imprimen nada, ni tocan el mundo exterior en absoluto — son funciones puras, que reciben datos y devuelven datos. Esa separación importa más de lo que parece, porque las funciones puras son exactamente el tipo que puedes meter directo en un bloque `#[cfg(test)] mod tests` de la lección anterior y verificar con asserts, sin tener que simular stdin ni el sistema de archivos. El único trabajo de `main` pasa a ser reunir la entrada, llamar a la lógica y reportar el resultado — una forma a veces llamada 'main delgado, biblioteca gorda' ('thin main, thick library'), y es el mismo instinto que puso tu lógica de negocio en `lib.rs` y tu punto de entrada en `main.rs` allá en la lección de módulos y crates.
También vale la pena ver cómo el resto de este curso extendería este mismo programa, aunque no lo construyamos aquí. Contar varios archivos a la vez encaja perfecto con `thread::spawn` y un canal `mpsc` de la lección de concurrencia — generas un hilo por archivo, cada uno envía de vuelta su propio conteo de palabras, y los combinas en el hilo principal. Si en cambio varias partes del programa necesitaran compartir y actualizar un mismo total corriendo al mismo tiempo, para eso existe exactamente `Arc<Mutex<HashMap<String, u32>>>`. Nada de eso cambia una sola línea de `tokenize` o `count_words` — solo cambia cómo se reúnen sus resultados, que es exactamente la recompensa de mantener la lógica principal pequeña y pura desde el principio.
Y con eso termina el curso. Empezaste con variables y ownership, pasaste por structs, enums y pattern matching, aprendiste a manejar la ausencia y el fallo de forma explícita con `Option` y `Result`, generalizaste tu código con genéricos y traits, y terminaste con las herramientas — punteros inteligentes, hilos, tests — que convierten 'un programa que compila' en 'un programa en el que puedes confiar en producción'. El contador de frecuencia de palabras que acabas de construir es pequeño a propósito, pero cada idea en él escala: cambia el string fijo por un archivo, agrega `clap` desde crates.io para un parseo de argumentos real, y tienes una herramienta de línea de comandos genuina. Desde aquí, el mejor próximo paso no es otra lección — es un proyecto pequeño propio, construido de la misma forma en que se construyó este: una etapa que compile a la vez.
fn tokenize(text: &str) -> Vec<String> {text.split_whitespace().map(|word| {word.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_lowercase()}).filter(|word| !word.is_empty()).collect()}fn main() {let sample = "The quick brown fox jumps over the lazy dog. The dog barks, but the fox runs away!";let words = tokenize(sample);println!("{:?}", words);}
Turning raw text into normalized words: split on whitespace, strip punctuation with a filtered character iterator, lowercase for case-insensitive counting, and drop anything that becomes empty — this is stage one of the pipeline, parsing.
use std::collections::HashMap;fn tokenize(text: &str) -> Vec<String> {text.split_whitespace().map(|word| {word.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_lowercase()}).filter(|word| !word.is_empty()).collect()}fn count_words(words: &[String]) -> HashMap<String, u32> {let mut counts = HashMap::new();for word in words {*counts.entry(word.clone()).or_insert(0) += 1;}counts}fn main() {let sample = "the quick brown fox the lazy dog the fox";let words = tokenize(sample);let counts = count_words(&words);println!("{:?}", counts);}
Stage two: fold the word list into a HashMap<String, u32> using the entry API, so each word's count is either created at 1 or incremented, in a single expression.
use std::collections::HashMap;use std::fmt;#[derive(Debug)]struct EmptyInputError;impl fmt::Display for EmptyInputError {fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {write!(f, "input text was empty after tokenizing")}}impl std::error::Error for EmptyInputError {}fn tokenize(text: &str) -> Vec<String> {text.split_whitespace().map(|word| {word.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_lowercase()}).filter(|word| !word.is_empty()).collect()}fn count_words(words: &[String]) -> HashMap<String, u32> {let mut counts = HashMap::new();for word in words {*counts.entry(word.clone()).or_insert(0) += 1;}counts}fn top_n_words(text: &str, n: usize) -> Result<Vec<(String, u32)>, EmptyInputError> {let words = tokenize(text);if words.is_empty() {return Err(EmptyInputError);}let counts = count_words(&words);let mut ranked: Vec<(String, u32)> = counts.into_iter().collect();ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));ranked.truncate(n);Ok(ranked)}fn main() {let sample = "The quick brown fox jumps over the lazy dog. The dog barks, but the fox runs away! The fox is quick.";match top_n_words(sample, 3) {Ok(top) => {println!("Top words:");for (word, count) in top {println!(" {word}: {count}");}}Err(e) => eprintln!("Error: {e}"),}}
Stage three: collect the HashMap into a sortable Vec, rank it by count (ties broken alphabetically for deterministic output), wrap the whole pipeline in a Result so an empty input is reported rather than silently producing nothing, and let main decide what to do with success or failure.
🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. In the capstone's `top_n_words` function, `counts.into_iter().collect::<Vec<(String, u32)>>()` is called before sorting, instead of sorting the `HashMap` directly. Why?