Ir al contenido

Leer y escribir archivos en Rust

Leer y escribir archivos es uno de los primeros momentos en que todo lo que aprendiste sobre Result y el operador ? deja de ser teoría. Casi cualquier operación con archivos puede fallar (puede que el archivo no exista o que no tengas permiso), así que la biblioteca estándar te obliga a tratar esos casos directamente.

La forma más sencilla de leer el contenido de un archivo en un String:

use std::fs;
fn main() {
let contents = fs::read_to_string("hello.txt");
match contents {
Ok(text) => println!("{text}"),
Err(e) => println!("No se pudo leer el archivo: {e}"),
}
}

fs::read_to_string devuelve un Result<String, std::io::Error>: Ok con el texto si todo fue bien, o Err con el motivo si algo falló (el archivo no existe, no tienes permiso, etc.).

Usar ? para simplificar el manejo de errores

Sección titulada «Usar ? para simplificar el manejo de errores»

Hacer un match en cada operación con archivos se vuelve repetitivo enseguida. Si estás dentro de una función que también devuelve un Result, el operador ? (de la página de Result) lo simplifica mucho:

use std::fs;
use std::io;
fn read_file(path: &str) -> Result<String, io::Error> {
let contents = fs::read_to_string(path)?; // si falla, sale de la función aquí
Ok(contents)
}
fn main() {
match read_file("hello.txt") {
Ok(text) => println!("{text}"),
Err(e) => println!("Error: {e}"),
}
}
use std::fs;
fn main() {
let result = fs::write("output.txt", "¡Hola, archivo!");
match result {
Ok(()) => println!("Se escribió correctamente"),
Err(e) => println!("No se pudo escribir: {e}"),
}
}

fs::write crea el archivo si no existe y, si ya existe, lo sobrescribe por completo. Tenlo en cuenta si lo que necesitas es añadir contenido a un archivo en lugar de reemplazarlo.

Para añadir contenido a un archivo existente sin sobrescribirlo, usa OpenOptions:

use std::fs::OpenOptions;
use std::io::Write;
fn main() -> std::io::Result<()> {
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open("log.txt")?;
writeln!(file, "Una nueva línea en el registro")?;
Ok(())
}

Fíjate en que aquí el propio main devuelve un Result. Es un patrón que conviene conocer: cuando main devuelve un Result, puedes usar ? directamente dentro de él en lugar de envolverlo todo en un match.

Con un archivo grande, cargarlo entero en memoria de una vez no siempre es buena idea. En su lugar, puedes leerlo línea a línea:

use std::fs::File;
use std::io::{BufRead, BufReader};
fn main() -> std::io::Result<()> {
let file = File::open("hello.txt")?;
let reader = BufReader::new(file);
for line in reader.lines() {
println!("{}", line?);
}
Ok(())
}

Hasta ahora, todos los errores se trataban igual: imprimiéndolos. Pero muchas veces quieres reaccionar de forma distinta según qué salió mal. Que falte un archivo suele estar bien (quizá es la primera ejecución y simplemente empiezas de cero). “Permiso denegado”, en cambio, es un problema real que deberías comunicar.

Un io::Error puede decirte su categoría con .kind(), que devuelve un enum ErrorKind (en inglés) sobre el que puedes hacer match:

use std::fs;
use std::io::ErrorKind;
fn main() {
// un archivo en la carpeta temporal del sistema que no existe
let path = std::env::temp_dir().join("rusttutorial-missing-notes.txt");
match fs::read_to_string(&path) {
Ok(text) => println!("Se cargaron {} líneas", text.lines().count()),
Err(e) => match e.kind() {
ErrorKind::NotFound => println!("Todavía no hay notas, empezamos de cero"),
ErrorKind::PermissionDenied => println!("No tienes permiso para leer el archivo de notas"),
_ => println!("Algo más salió mal: {e}"),
},
}
}

Salida:

Todavía no hay notas, empezamos de cero
▶ Pruébalo tú mismo

std::env::temp_dir() te da la ruta de la carpeta temporal del sistema (como /tmp en Linux), y .join(...) le añade un nombre de archivo. Las funciones de archivos aceptan una ruta así igual de bien que un &str.

Deja siempre un brazo _ al final: ErrorKind tiene muchas variantes, y con el tiempo se añaden otras nuevas. El brazo _ atrapa todo lo que no hayas listado.

Los dos ejercicios escriben primero su propio archivo en la carpeta temporal, así que funcionan en cualquier sitio, incluido el Playground.

Escribe tres tareas pendientes en un archivo con fs::write, una por línea. Después vuelve a leer el archivo con fs::read_to_string e imprime cada línea con un número delante y, al final, el total.

Pista: pon \n entre las tareas para separarlas en líneas. text.lines() te da las líneas de una en una. Haz que main devuelva std::io::Result<()> para poder usar ?.

Ver solución
use std::fs;
fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("rusttutorial-todo.txt");
fs::write(&path, "aprender ownership\nescribir un módulo\nleer un archivo\n")?;
let text = fs::read_to_string(&path)?;
let mut count = 0;
for line in text.lines() {
count += 1;
println!("{count}. {line}");
}
println!("{count} tareas");
Ok(())
}

Salida:

1. aprender ownership
2. escribir un módulo
3. leer un archivo
3 tareas
▶ Pruébalo tú mismo

Como fs::write reemplaza el archivo entero, obtienes las mismas tres líneas sin importar cuántas veces lo ejecutes. Cada ? devuelve el error desde main si falla la escritura o la lectura.

Empieza un archivo de registro (log) desde cero con una línea de encabezado y luego añade dos líneas más con OpenOptions y writeln!. Por último, lee el archivo completo e imprímelo.

Pista: usa fs::write para el encabezado (así cada ejecución empieza limpia) y luego OpenOptions::new().append(true).open(&path)? para las líneas extra. print! (no println!) evita una línea en blanco de más al final, porque el archivo ya termina con un salto de línea.

Ver solución
use std::fs::{self, OpenOptions};
use std::io::Write;
fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("rusttutorial-log.txt");
fs::write(&path, "=== registro ===\n")?; // empezar de cero
let mut file = OpenOptions::new().append(true).open(&path)?;
writeln!(file, "iniciado")?;
writeln!(file, "terminado")?;
print!("{}", fs::read_to_string(&path)?);
Ok(())
}

Salida:

=== registro ===
iniciado
terminado
▶ Pruébalo tú mismo

use std::fs::{self, OpenOptions}; trae a la vez el propio módulo fs y OpenOptions. Sin el primer fs::write, cada ejecución seguiría añadiendo líneas al archivo anterior y el registro crecería sin fin. Eso es justo lo que quieres en un registro real, pero no en un ejercicio con una salida fija.

  • fs::read_to_string(path) lee un archivo completo en un String y devuelve un Result.
  • fs::write(path, contents) escribe un archivo y lo sobrescribe si ya existe.
  • OpenOptions::new().append(true) te permite añadir contenido a un archivo en lugar de reemplazarlo.
  • main() -> std::io::Result<()> te permite usar ? directamente en main, sin hacer match en cada llamada.
  • e.kind() te dice qué tipo de error ocurrió, como ErrorKind::NotFound, para que puedas tratar cada uno de forma distinta.
  • Los ejemplos que usan archivos de la carpeta de tu proyecto es mejor probarlos en tu equipo; std::env::temp_dir() te da una carpeta que funciona en cualquier sitio.

Comprueba lo aprendido

1. ¿Qué pasa si llamas a fs::write con una ruta que ya existe?

2. ¿Cómo añades contenido a un archivo en lugar de sobrescribirlo?

3. ¿Qué te permite que main devuelva std::io::Result<()>?

4. ¿Qué hace el operador ? cuando fs::read_to_string(path)? falla?

5. ¿Por qué leer un archivo grande línea a línea con BufReader en lugar de usar fs::read_to_string?

Puntuación: 0 / 5