Refactorizando para mejorar la modularidad y el manejo de errores

Para mejorar nuestro programa, solucionaremos cuatro problemas que tienen que ver con la estructura del programa y cómo maneja los errores potenciales. En primer lugar, nuestra función main ahora realiza dos tareas: analiza los argumentos y lee los archivos. A medida que nuestro programa crece, el número de tareas separadas que maneja la función main aumentará. A medida que una función adquiere responsabilidades, se vuelve más difícil de razonar, más difícil de probar y más difícil de cambiar sin romper una de sus partes. Es mejor separar la funcionalidad para que cada función sea responsable de una tarea.

Este problema también está relacionado con el segundo problema: aunque query y file_path son variables de configuración para nuestro programa, variables como contents se utilizan para realizar la lógica del programa. Cuanto más largo sea main, más variables necesitaremos para traer al alcance; Cuantas más variables tengamos en el alcance, más difícil será realizar un seguimiento del propósito de cada una. Es mejor agrupar las variables de configuración en una estructura para que su propósito quede claro.

El tercer problema es que hemos usado expect para imprimir un mensaje de error cuando falla la lectura del archivo, pero el mensaje de error solo imprime Should have been able to read the file. La lectura de un archivo puede fallar de varias maneras: por ejemplo, el archivo podría faltar, o podríamos no tener permiso para abrirlo. En este momento, independientemente de la situación, imprimiríamos el mismo mensaje de error para todo, ¡lo que no le daría al usuario ninguna información!

Cuarto, usamos expect repetidamente para manejar un error, y si el usuario ejecuta nuestro programa sin especificar suficientes argumentos, obtendrán un error de índice fuera de límites de Rust que no explica claramente el problema. Sería mejor si todo el código de manejo de errores estuviera en un solo lugar para que los futuros mantenedores tuvieran un solo lugar para consultar el código si la lógica de manejo de errores necesitaba cambiar. Tener todo el código de manejo de errores en un solo lugar también asegurará que estamos imprimiendo mensajes que serán significativos para nuestros usuarios finales.

Abordemos estos cuatro problemas refactorizando nuestro proyecto.

Separacion de preocupaciones para proyectos binarios

El problema organizativo de asignar la responsabilidad de múltiples tareas a la función main es común a muchos proyectos binarios. Como resultado, la comunidad de Rust ha desarrollado pautas para dividir las preocupaciones separadas de un programa binario cuando main comienza a crecer. Este proceso tiene los siguientes pasos:

  • Divide tu programa en un main.rs y un lib.rs y mueve la lógica de tu programa a lib.rs.
  • Mientras la lógica de análisis de línea de comandos sea pequeña, puede permanecer en main.rs.
  • Cuando la lógica de análisis de línea de comandos comience a complicarse, extráela de main.rs y muévala a lib.rs.

Las responsabilidades que quedan en la función main después de este proceso deberían limitarse a lo siguiente:

  • Llamar a la lógica de análisis de línea de comandos con los valores de argumento
  • Configuración de cualquier otra configuración
  • Llamando a una función run en lib.rs
  • Manejo del error si run devuelve un error

Este patrón se trata de separar las preocupaciones: main.rs maneja la ejecución del programa, y lib.rs maneja toda la lógica de la tarea en cuestión. Debido a que no puede probar la función main directamente, esta estructura le permite probar toda la lógica de su programa moviéndola a funciones en lib.rs. El código que permanece en main.rs será lo suficientemente pequeño como para verificar su corrección leyéndolo. Rehagamos nuestro programa siguiendo este proceso.

Extracción del parser de argumentos

Extraeremos la funcionalidad para analizar los argumentos en una función que main llamará para prepararse para mover la lógica de análisis de línea de comandos a src/lib.rs. La lista 12-5 muestra el nuevo inicio de main que llama a una nueva función parse_config, que definiremos en src/main.rs por el momento.

Filename: src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let (query, file_path) = parse_config(&args);

    // --snip--

    println!("Searching for {query}");
    println!("In file {file_path}");

    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let file_path = &args[2];

    (query, file_path)
}

Listing 12-5: Extrayendo una función parse_config de main

En este cambio, aún estamos recopilando los argumentos de la línea de comandos en un vector, pero en lugar de asignar el valor del argumento en el índice 1 a la variable query y el valor del argumento en el índice 2 a la variable file_path dentro de la función main, pasamos todo el vector a la función parse_config. La función parse_config luego tiene la lógica que determina qué argumento va en qué variable y pasa los valores de vuelta a main. Todavía creamos las variables query y file_path en main, pero main ya no tiene la responsabilidad de determinar cómo se corresponden los argumentos de la línea de comandos y las variables.

Esta reorganización puede parecer excesiva para nuestro pequeño programa, pero estamos refactorizando en pequeños pasos incrementales. Después de hacer este cambio, ejecute el programa nuevamente para verificar que el análisis de argumentos aún funcione. Es bueno verificar su progreso con frecuencia, para ayudar a identificar la causa de los problemas cuando ocurren.

Agrupación de valores de configuración

Podemos dar otro pequeño paso para mejorar aún más la función parse_config. En este momento, estamos devolviendo una tupla, pero luego rompemos esa tupla en partes individuales nuevamente. Esto es una señal de que tal vez no tenemos la abstracción correcta todavía.

Otro indicador que muestra que hay margen de mejora es la parte config de parse_config, que implica que los dos valores que devolvemos están relacionados y ambos son parte de un valor de configuración. Actualmente, no estamos transmitiendo este significado en el struct de los datos que no sea agrupar los dos valores en una tupla; en su lugar, pondremos los dos valores en un struct y daremos a cada uno de los campos del struct un nombre significativo. Hacerlo hará que sea más fácil para los futuros mantenedores de este código comprender cómo se relacionan los diferentes valores entre sí y cuál es su propósito.

Listing 12-6 muestra las mejoras a la función parse_config.

Filename: src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = parse_config(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    // --snip--

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let file_path = args[2].clone();

    Config { query, file_path }
}

Listing 12-6: Refactorizando parse_config para que devuelva una instancia de un struct Config

Hemos agregado un struct llamado Config definido para tener campos llamados query y file_path. La firma de parse_config ahora índica que devuelve un valor Config. En el cuerpo de parse_config, donde solíamos devolver rebanadas de cadena que hacen referencia a valores String en args, ahora definimos Config para contener valores String de propiedad. La variable args en main es el propietario de los valores de argumento y solo permite que la función parse_config los pida prestados, lo que significa que violaríamos las reglas de préstamo de Rust si Config intentara tomar posesión de los valores en args.

Hay varias formas de administrar los datos de String; la más fácil, aunque algo ineficiente, es llamar al método clone en los valores. Esto hará una copia completa de los datos para que la instancia de Config posea, lo que toma más tiempo y memoria que almacenar una referencia a los datos de string. Sin embargo, clonar los datos también hace que nuestro código sea muy sencillo porque no tenemos que administrar los lifetimes de las referencias; en estas circunstancias, renunciar a un poco de rendimiento para ganar simplicidad es un intercambio válido.

Los intercambios de usar clone

Hay una tendencia entre muchos Rustaceans a evitar usar clone para solucionar problemas de propiedad debido a su costo de tiempo de ejecución. En el capítulo 13, aprenderá a usar métodos más eficientes en este tipo de situaciones. Pero por ahora, está bien copiar algunas cadenas para seguir progresando porque solo harás estas copias una vez y tu ruta de archivo y cadena de consulta son muy pequeñas. Es mejor tener un programa que funcione un poco ineficiente que intentar hiperoptimizar el código en tu primer paso. A medida que adquieras más experiencia con Rust, será más fácil comenzar con la solución más eficiente, , pero por ahora, es perfectamente aceptable llamar a clone.

Hemos actualizado main para que coloque la instancia de Config devuelta por parse_config en una variable llamada config, y hemos actualizado el código que anteriormente usaba las variables separadas query y file_path para que ahora use los campos en el struct Config en su lugar.

Ahora nuestro código transmite más claramente que query y file_path están relacionados y que su propósito es configurar cómo funcionará el programa. Cualquier código que use estos valores sabe que debe buscarlos en la instancia config en los campos nombrados por su propósito.

Creando un constructor para Config

Hasta ahora, hemos extraído la lógica responsable de analizar los argumentos de la línea de comandos de main y la hemos colocado en la función parse_config. Hacerlo nos ayudó a ver que los valores query y file_path estaban relacionados y que esa relación debería transmitirse en nuestro código. Luego agregamos un struct Config para nombrar el propósito relacionado de query y file_path y poder devolver los nombres de los valores como nombres de campo de struct desde la función parse_config.

Así que ahora el propósito de la función parse_config es crear una instancia de Config, podemos cambiar parse_config de una función normal a una función llama new que es asociada con Config. que esté asociada con el struct Config. Haciendo este cambio, el código será más idiomático. Podemos crear instancias de tipos en la biblioteca estándar, como String, llamando a String::new. De manera similar, al cambiar parse_config a una función asociada con Config, podremos crear instancias de Config llamando a Config::new. El listado 12-7 muestra los cambios que debemos hacer.

Filename: src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");

    // --snip--
}

// --snip--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}

Listing 12-7: Cambiando parse_config a Config::new

Hemos actualizado main donde estábamos llamando a parse_config para que en su lugar llame a Config::new. Hemos cambiado el nombre de parse_config a new y lo hemos movido dentro de un bloque impl, que asocia la función new con Config. Intenta compilar este código nuevamente para asegurarte de que funciona.

Arreglando el manejo de errores

Ahora trabajaremos en la corrección de nuestro manejo de errores. Recuerda que intentar acceder a los valores en el vector args en el índice 1 o el índice 2 hará que el programa entre en pánico si el vector contiene menos de tres elementos. Intenta ejecutar el programa sin ningún argumento; se verá así:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`
thread 'main' panicked at src/main.rs:27:21:
index out of bounds: the len is 1 but the index is 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

La línea index out of bounds: the len is 1 but the index is 1 es un mensaje de error destinado a los programadores. No ayudará a nuestros usuarios finales a comprender lo que deben hacer en su lugar. Arreglemos eso ahora.

Mejorando el mensaje de error

En el Listado 12-8, agregamos una verificación en la función new que verificará que el slice sea lo suficientemente largo antes de acceder al índice 1 y 2. Si el slice no es lo suficientemente largo, el programa entra en pánico y muestra un mensaje de error mejor.

Filename: src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    // --snip--
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("not enough arguments");
        }
        // --snip--

        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}

Listing 12-8: Agregando una verificación para el número de argumentos

Este código es similar a la función Guess::new que escribimos en el Listado 9-13, donde llamamos a panic! cuando el argumento value estaba fuera del rango de valores válidos. En lugar de verificar un rango de valores aquí, estamos verificando que la longitud de args sea al menos 3 y el resto de la función puede operar bajo la suposición de que esta condición se ha cumplido. Si args tiene menos de tres elementos, esta condición será verdadera y llamaremos a la macro panic! para finalizar el programa inmediatamente.

Con estas pocas líneas de código adicionales en new, ejecutemos el programa sin ningún argumento nuevamente para ver cómo se ve el error ahora:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`
thread 'main' panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Este output es mejor: ahora tenemos un mensaje de error razonable. Sin embargo, también tenemos información superflua que no queremos dar a nuestros usuarios. Quizás usar la técnica que usamos en el Listado 9-13 no es la mejor para usar aquí: una llamada a panic! es más apropiada para un problema de programación que para un problema de uso, como se discutió en el Capítulo 9. En su lugar, usaremos la otra técnica que aprendiste en el Capítulo 9: devolver un Result que indique el éxito o un error.

Devolver un Result en lugar de llamar a panic!

En su lugar, podemos devolver un Result que contendrá una instancia de Config en el caso de éxito y describirá el problema en el caso de error. También cambiaremos el nombre de la función de new a build porque muchos programadores esperan que las funciones new nunca fallen. Cuando Config::build se comunique con main, podemos usar el tipo Result para señalar que hubo un problema. Luego podemos cambiar main para convertir una variante Err en un error más práctico para nuestros usuarios sin el texto circundante sobre thread 'main' y RUST_BACKTRACE que una llamada a panic! provoca.

El Listado 12-9 muestra los cambios que debemos hacer en el valor de retorno de la función que ahora llamamos Config::build y el cuerpo de la función necesario para devolver un Result. Ten en cuenta que esto no se compilará hasta que actualicemos main también, lo cual haremos en el siguiente listado.

Filename: src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

Listing 12-9: Devolviendo un Result desde Config::build

Nuestra función build devuelve un Result con una instancia de Config en el caso de éxito y una referencia a un string en el caso de error. Nuestros valores de error siempre serán string literals que tengan el lifetime 'static.

Hemos hecho dos cambios en el cuerpo de la función: en lugar de llamar a panic! cuando el usuario no pasa suficientes argumentos, ahora devolvemos un valor Err, y hemos envuelto el valor de retorno Config en un Ok. Estos cambios hacen que la función se ajuste a su nueva firma de tipo.

Devolviendo un valor Err desde Config::build permite que la función main maneje el Result devuelto por la función build y salga del proceso de manera más limpia en el caso de error.

Llamando a Config::build y manejando errores

Para manejar el caso de error e imprimir un mensaje amigable para el usuario, necesitamos actualizar main para manejar el Result que devuelve Config::build, como se muestra en el Listado 12-10. También tomaremos la responsabilidad de salir de la herramienta de línea de comandos con un código de error distinto de cero de panic! e implementarlo a mano. Un estado de salida distinto de cero es una convención para señalar al proceso que llamó a nuestro programa que el programa salió con un estado de error.

Filename: src/main.rs

use std::env;
use std::fs;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

Listing 12-10: Saliendo con un código de error si falla la construcción de una Config

En este listado, hemos usado un método que aún no hemos cubierto en detalle: unwrap_or_else, que está definido en Result<T, E> por la biblioteca estándar. Usar unwrap_or_else nos permite definir un manejo de errores personalizado que no sea panic!. Si el Result es un valor Ok, el comportamiento de este método es similar a unwrap: devuelve el valor interno que Ok está envolviendo. Sin embargo, si el valor es un valor Err, este método llama al código en el closure, que es una función anónima que definimos y pasamos como argumento a unwrap_or_else. Cubriremos los closures con más detalle en el Capítulo 13. Por ahora, solo necesitas saber que unwrap_or_else pasará el valor interno del Err, que en este caso es el string estático "not enough arguments" que agregamos en el Listado 12-9, a nuestro closure en el argumento err que aparece entre las barras verticales |. El código en el closure imprime el valor de err cuando se ejecuta.

Hemos agregado una nueva línea use para traer process de la biblioteca estándar al alcance. El código en el closure que se ejecutará en el caso de error es solo de dos líneas: imprimimos el valor de err y luego llamamos a process::exit. La función process::exit detendrá el programa inmediatamente y devolverá el número que se pasó como código de estado de salida. Esto es similar al manejo basado en panic! que usamos en el Listado 12-8, pero ya no obtenemos todo el output extra. ¡Probémoslo!

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments

¡Genial! Este output es mucho más amigable para nuestros usuarios.

Extrayendo la lógica de main

Ahora que hemos terminado de refactorizar el análisis de configuración, pasemos a la lógica del programa. Como dijimos en “Separación de preocupaciones para proyectos binarios” , extraeremos una función llamada run que contendrá toda la lógica actualmente en la función main que no está involucrada con la configuración o el manejo de errores. Cuando terminemos, main será conciso y fácil de verificar por inspección, y podremos escribir pruebas para toda la otra lógica.

El Listado 12-11 muestra la función run extraída. Por ahora, solo estamos haciendo la pequeña mejora incremental de extraer la función. Todavía estamos definiendo la función en src/main.rs.

Filename: src/main.rs

use std::env;
use std::fs;
use std::process;

fn main() {
    // --snip--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) {
    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

// --snip--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

Listing 12-11: Extracting a run function containing the rest of the program logic

La función run ahora contiene toda la lógica restante de main, comenzando desde la lectura del archivo. La función run toma la instancia de Config como argumento.

Devolviendo errores desde la función run

Con la lógica del programa restante separada en la función run, podemos mejorar el manejo de errores, como hicimos con Config::build en el Listado 12-9. En lugar de permitir que el programa entre en pánico llamando a expect, la función run devolverá un Result<T, E> cuando algo salga mal. Esto nos permitirá consolidar aún más la lógica que rodea el manejo de errores en main de una manera amigable para el usuario. El Listado 12-12 muestra los cambios que debemos hacer en la firma y el cuerpo de run.

Filename: src/main.rs

use std::env;
use std::fs;
use std::process;
use std::error::Error;

// --snip--


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

Listing 12-12: Cambiando la función run para devolver Result

Hemos realizado tres cambios significativos aquí. Primero, cambiamos el tipo de retorno de la función run a Result<(), Box<dyn Error>>. Esta función anteriormente devolvía el tipo unitario, (), y lo mantenemos como el valor devuelto en el caso Ok.

Para el tipo de error, usamos el trait object Box<dyn Error> (y hemos traído std::error::Error al alcance con una declaración use en la parte superior). Cubriremos los trait objects en el Capítulo 17. Por ahora, solo sepa que Box<dyn Error> significa que la función devolverá un tipo que implementa el trait Error, pero no tenemos que especificar qué tipo particular será el valor de retorno. Esto nos da flexibilidad para devolver valores de error que pueden ser de diferentes tipos en diferentes casos de error. La palabra clave dyn es corta para “dynamic”.

Segundo, hemos eliminado la llamada a expect en favor del operador ?, como hablamos en el Capítulo 9. En lugar de panic! en un error, ? devolverá el valor de error de la función actual para que el llamador lo maneje.

Tercero, la función run ahora devuelve un valor Ok en caso de éxito. Hemos declarado con éxito la función run como () en la firma, lo que significa que necesitamos envolver el valor unitario en el valor Ok. Esta sintaxis Ok(()) puede parecer un poco extraña al principio, pero usar () de esta manera es la forma idiomática de indicar que estamos llamando a run solo por sus efectos secundarios; no devuelve un valor que necesitamos.

Cuando ejecutamos el código, se compila, pero no muestra nada:

$ cargo run -- the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
  --> src/main.rs:19:5
   |
19 |     run(config);
   |     ^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
   |
19 |     let _ = run(config);
   |     +++++++

warning: `minigrep` (bin "minigrep") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.71s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

Rust nos dice que nuestro código ignoró el valor Result y el valor Result podría indicar que ocurrió un error. Pero no estamos comprobando si hubo un error o no, ¡y el compilador nos recuerda que probablemente quisimos tener algo de código de manejo de errores aquí! Corrijamos ese problema ahora.

Manejando errores devueltos por run en main

Comprobaremos los errores y los manejaremos usando una técnica similar a la que usamos con Config::build en el Listado 12-10, pero con una ligera diferencia:

Filename: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

fn main() {
    // --snip--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

Usamos if let en lugar de unwrap_or_else para verificar si run devuelve un valor Err y llamar a process::exit(1) si lo hace. La función run no devuelve un valor que queremos unwrap de la misma manera que Config::build devuelve la instancia de Config. Debido a que run devuelve () en el caso de éxito, solo nos importa detectar un error, por lo que no necesitamos que unwrap_or_else devuelva el valor desempaquetado, que solo sería ().

Los cuerpos de las funciones if let y unwrap_or_else son los mismos en ambos casos: imprimimos el error y salimos.

Dividiendo el código en un crate de biblioteca

Nuestro proyecto minigrep se ve bien hasta ahora. Ahora dividiremos el archivo src/main.rs y pondremos parte del código en el archivo src/lib.rs. De esa manera podemos probar el código y tener un archivo src/main.rs con menos responsabilidades.

Vamos a mover todo el código que no sea la función main de src/main.rs a src/lib.rs:

  • La función run
  • Las declaraciones use relevantes
  • La definición de Config
  • La función Config::build

El contenido de src/main.rs debería tener la firma que se muestra en el Listado 12-13 (omitimos los cuerpos de las funciones por brevedad). Ten en cuenta que esto no se compilará hasta que modifiquemos src/main.rs en el Listado 12-14.

Filename: src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        // --snip--
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    // --snip--
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

Listing 12-13: Moviendo Config y run a src/lib.rs

Hemos hecho uso de la palabra clave pub: en Config, en sus campos y en su método build, y en la función run. ¡Ahora tenemos un crate de biblioteca que tiene una API pública que podemos probar!.

Ahora necesitamos traer el código que movimos a src/lib.rs al scope del crate binario en src/main.rs, como se muestra en el Listado 12-14.

Filename: src/main.rs

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    // --snip--
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    if let Err(e) = minigrep::run(config) {
        // --snip--
        println!("Application error: {e}");
        process::exit(1);
    }
}

Listing 12-14: Usando el crate biblioteca minigrep en src/main.rs

Agregamos una línea use minigrep::Config para traer el tipo Config desde el crate de biblioteca al scope del crate binario, y agregamos el prefijo minigrep:: a la llamada a run. Ahora toda la funcionalidad debería estar conectada y debería funcionar. Ejecuta el programa con cargo run y asegúrate de que todo funcione correctamente.

¡Uf! Eso fue mucho trabajo, pero nos hemos preparado para el éxito en el futuro. Ahora es mucho más fácil manejar errores, y hemos hecho que el código sea más modular. Casi todo nuestro trabajo se hará en src/lib.rs a partir de ahora.

¡Aprovechemos esta nueva modularidad haciendo algo que habría sido difícil con el código antiguo, pero es fácil con el nuevo código: escribiremos algunas pruebas!