Aller au contenu

Lire et écrire des fichiers en Rust

Lire et écrire des fichiers, c’est l’un des premiers endroits où tout ce que vous avez appris sur Result et l’opérateur ? cesse d’être théorique. Presque toutes les opérations sur les fichiers peuvent échouer (le fichier n’existe peut-être pas, vous n’avez peut-être pas les droits), donc la bibliothèque standard vous oblige à gérer ces cas directement.

La façon la plus simple de lire le contenu d’un fichier dans une String :

use std::fs;
fn main() {
let contents = fs::read_to_string("hello.txt");
match contents {
Ok(text) => println!("{text}"),
Err(e) => println!("Impossible de lire le fichier : {e}"),
}
}

fs::read_to_string renvoie un Result<String, std::io::Error> : Ok avec le texte si tout s’est bien passé, Err avec une raison sinon (fichier absent, pas les droits, etc.).

Faire un match sur chaque opération de fichier devient vite répétitif. Si vous êtes dans une fonction qui renvoie elle-même un Result, l’opérateur ? (vu sur la page Result) allège beaucoup le code :

use std::fs;
use std::io;
fn read_file(path: &str) -> Result<String, io::Error> {
let contents = fs::read_to_string(path)?; // sort tout de suite en cas d'échec
Ok(contents)
}
fn main() {
match read_file("hello.txt") {
Ok(text) => println!("{text}"),
Err(e) => println!("Erreur : {e}"),
}
}
use std::fs;
fn main() {
let result = fs::write("output.txt", "Bonjour, fichier !");
match result {
Ok(()) => println!("Écriture réussie"),
Err(e) => println!("Échec de l'écriture : {e}"),
}
}

fs::write crée le fichier s’il n’existe pas, et l’écrase complètement s’il existe déjà. Gardez-le en tête si vous voulez ajouter du contenu à un fichier plutôt que le remplacer.

Pour ajouter du contenu à un fichier existant au lieu de l’écraser, utilisez 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, "Une nouvelle ligne de journal")?;
Ok(())
}

Remarquez qu’ici, main renvoie elle-même un Result. C’est un schéma à connaître : quand main renvoie un Result, vous pouvez utiliser ? directement dedans, au lieu de tout envelopper dans un match.

Pour un gros fichier, tout charger en mémoire d’un coup n’est pas toujours idéal. Voici comment le lire ligne par ligne :

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(())
}

Jusqu’ici, toutes les erreurs étaient traitées de la même façon : on les affichait. Mais souvent, vous voulez réagir différemment selon ce qui s’est mal passé. Un fichier absent n’est généralement pas grave (c’est peut-être le premier lancement, et vous partez de zéro). Une « permission refusée », en revanche, est un vrai problème à signaler.

Un io::Error peut vous donner sa catégorie avec .kind(), qui renvoie une enum ErrorKind sur laquelle vous pouvez faire un match :

use std::fs;
use std::io::ErrorKind;
fn main() {
// un fichier du dossier temporaire du système qui n'existe pas
let path = std::env::temp_dir().join("rusttutorial-missing-notes.txt");
match fs::read_to_string(&path) {
Ok(text) => println!("{} lignes chargées", text.lines().count()),
Err(e) => match e.kind() {
ErrorKind::NotFound => println!("Pas encore de notes, on part de zéro"),
ErrorKind::PermissionDenied => println!("Pas le droit de lire le fichier de notes"),
_ => println!("Autre problème : {e}"),
},
}
}

Sortie :

Pas encore de notes, on part de zéro
▶ Essayez par vous-même

std::env::temp_dir() vous donne le chemin du dossier temporaire du système (par exemple /tmp sous Linux), et .join(...) y ajoute un nom de fichier. Les fonctions sur les fichiers acceptent un chemin comme celui-ci aussi bien qu’un &str.

Gardez toujours une branche _ à la fin : ErrorKind a beaucoup de variantes, et de nouvelles apparaissent au fil du temps. La branche _ attrape tout ce que vous n’avez pas listé.

Les deux exercices écrivent d’abord leur propre fichier dans le dossier temporaire : ils fonctionnent donc partout, y compris dans le Playground.

Écrivez trois tâches dans un fichier avec fs::write, une par ligne. Relisez ensuite le fichier avec fs::read_to_string et affichez chaque ligne précédée d’un numéro, puis le total.

Indice : mettez \n entre les tâches pour obtenir des lignes séparées. text.lines() vous donne les lignes une par une. Faites renvoyer std::io::Result<()> à main pour pouvoir utiliser ?.

Voir la solution
use std::fs;
fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("rusttutorial-todo.txt");
fs::write(&path, "apprendre l'ownership\nécrire un module\nlire un fichier\n")?;
let text = fs::read_to_string(&path)?;
let mut count = 0;
for line in text.lines() {
count += 1;
println!("{count}. {line}");
}
println!("{count} tâches");
Ok(())
}

Sortie :

1. apprendre l'ownership
2. écrire un module
3. lire un fichier
3 tâches
▶ Essayez par vous-même

Comme fs::write remplace tout le fichier, vous obtenez les trois mêmes lignes quel que soit le nombre de lancements. Chaque ? fait sortir l’erreur de main si l’écriture ou la lecture échoue.

Créez un fichier de journal tout neuf avec une ligne d’en-tête, puis ajoutez-y deux lignes avec OpenOptions et writeln!. Enfin, relisez tout le fichier et affichez-le.

Indice : utilisez fs::write pour l’en-tête (ainsi chaque lancement repart de zéro), puis OpenOptions::new().append(true).open(&path)? pour les lignes suivantes. print! (et non println!) évite une ligne vide en trop à la fin, car le fichier se termine déjà par un retour à la ligne.

Voir la solution
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, "=== journal ===\n")?; // on repart de zéro
let mut file = OpenOptions::new().append(true).open(&path)?;
writeln!(file, "démarrage")?;
writeln!(file, "fin")?;
print!("{}", fs::read_to_string(&path)?);
Ok(())
}

Sortie :

=== journal ===
démarrage
fin
▶ Essayez par vous-même

use std::fs::{self, OpenOptions}; importe à la fois le module fs lui-même et OpenOptions. Sans le premier fs::write, chaque lancement continuerait d’ajouter des lignes à l’ancien fichier, et le journal grossirait à l’infini. C’est exactement ce qu’on veut pour un vrai journal, mais pas pour un exercice dont la sortie doit rester fixe.

  • fs::read_to_string(path) lit un fichier entier dans une String et renvoie un Result.
  • fs::write(path, contents) écrit un fichier, en l’écrasant s’il existe déjà.
  • OpenOptions::new().append(true) permet d’ajouter du contenu à un fichier au lieu de le remplacer.
  • main() -> std::io::Result<()> vous permet d’utiliser ? directement dans main, au lieu de faire un match sur chaque appel.
  • e.kind() vous indique quel type d’erreur s’est produit, comme ErrorKind::NotFound, pour traiter chaque cas différemment.
  • Les exemples qui utilisent des fichiers du dossier de votre projet sont à essayer en local ; std::env::temp_dir() vous donne un dossier qui fonctionne partout.

Petit quiz

1. Que se passe-t-il si vous appelez fs::write sur un chemin qui existe déjà ?

2. Comment ajouter du contenu à un fichier au lieu de l'écraser ?

3. Qu'est-ce que ça permet quand main renvoie elle-même std::io::Result<()> ?

4. Que fait l'opérateur ? quand fs::read_to_string(path)? échoue ?

5. Pourquoi lire un gros fichier ligne par ligne avec BufReader plutôt qu'avec fs::read_to_string ?

Score : 0 / 5