// ⛔ INTERDIT : unwrap_or / unwrap_or_default / unwrap_or_else / unwrap / expect / anyhow / opérateur ? / await (sans match)
//
// detecteur-panic-points — détecte les points de panique potentiels
// (`.unwrap()`, `.expect(`, `panic!(`, `unreachable!(`, `todo!(`,
// `unimplemented!(`) en dehors du code de test, dans du code source Rust.
// Un panic en production sur un chemin non testé peut faire tomber un
// thread entier (ou tout le processus si le panic survient hors d'un
// contexte récupérable) — cette règle est déjà une règle absolue du projet
// qui a produit ce skill (voir feedback_regles_codage_fondamentales),
// formalisée ici en outil réutilisable. Stdlib uniquement, zéro dépendance
// externe, zéro appel réseau.
//
// Exclusion du code de test : suivi de profondeur d'accolades/parenthèses
// caractère par caractère (même technique que detecteur-async-bloquant),
// avec exclusion des chaînes de caractères (normales + brutes r#"..."#) et
// des commentaires — sans quoi un exemple de code dans une chaîne de test
// (fixture représentant du code Rust fictif) serait faussement attribué au
// code réel, comme observé sur ce projet lui-même lors de la validation de
// detecteur-async-bloquant.
//
// Substitution délibérée (même raison que les 2 autres skills Rust du
// projet) : `eprintln!` remplace `tracing` — outil CLI ponctuel, pas un
// serveur long-running.
use std::fs;
use std::path::{Path, PathBuf};
const DOSSIERS_IGNORES: &[&str] = &["target", ".git", "node_modules"];
const MARQUEUR_EXEMPTION_LIGNE: &str = "panic-ok";
const MOTIFS_PANIQUE: &[&str] = &[
".unwrap()",
".expect(",
"panic!(",
"unreachable!(",
"todo!(",
"unimplemented!(",
];
#[derive(Debug)]
enum Erreur {
ArgumentManquant,
DossierInvalide(String),
LectureFichier { chemin: String, source: std::io::Error },
}
impl std::fmt::Display for Erreur {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Erreur::ArgumentManquant => write!(f, "argument dossier_cible manquant"),
Erreur::DossierInvalide(chemin) => write!(f, "dossier invalide : {chemin}"),
Erreur::LectureFichier { chemin, source } => {
write!(f, "lecture de {chemin} echouee : {source}")
}
}
}
}
#[derive(Debug, Clone)]
struct Trouvaille {
fichier: String,
ligne: usize,
extrait: String,
motif_declencheur: String,
}
fn correspond_a(motif: &str, caracteres: &[char], position: usize) -> bool {
let motif_car: Vec<char> = motif.chars().collect();
if position + motif_car.len() > caracteres.len() {
return false;
}
caracteres[position..position + motif_car.len()] == motif_car[..]
}
/// Si la position `i` est le début d'une chaîne de caractères (normale ou
/// brute) ou d'un commentaire (`//` ou `/* */`), retourne la position juste
/// après la fin de cette construction. Sinon None. Fonction PURE.
fn sauter_chaine_ou_commentaire(caracteres: &[char], i: usize) -> Option<usize> {
if correspond_a("//", caracteres, i) {
let mut j = i;
while j < caracteres.len() && caracteres[j] != '\n' {
j += 1;
}
return Some(j);
}
if correspond_a("/*", caracteres, i) {
let mut j = i + 2;
while j + 1 < caracteres.len() && !(caracteres[j] == '*' && caracteres[j + 1] == '/') {
j += 1;
}
return Some((j + 2).min(caracteres.len()));
}
if caracteres[i] == 'r' {
let mut nb_dieses = 0usize;
let mut j = i + 1;
while j < caracteres.len() && caracteres[j] == '#' {
nb_dieses += 1;
j += 1;
}
if j < caracteres.len() && caracteres[j] == '"' {
j += 1;
loop {
if j >= caracteres.len() {
return Some(j);
}
if caracteres[j] == '"' {
let mut compte = 0usize;
let mut k = j + 1;
while k < caracteres.len() && caracteres[k] == '#' && compte < nb_dieses {
compte += 1;
k += 1;
}
if compte == nb_dieses {
return Some(k);
}
}
j += 1;
}
}
}
if caracteres[i] == '"' {
let mut j = i + 1;
while j < caracteres.len() {
if caracteres[j] == '\\' {
j += 2;
continue;
}
if caracteres[j] == '"' {
return Some(j + 1);
}
j += 1;
}
return Some(j);
}
None
}
/// Construit, pour tout le fichier, un masque "ce caractère appartient-il à
/// du vrai code" (false = à l'intérieur d'une chaîne ou d'un commentaire).
/// Fonction PURE — une seule passe, réutilisée par toutes les recherches.
fn construire_masque_code(caracteres: &[char]) -> Vec<bool> {
let mut masque = vec![true; caracteres.len()];
let mut i = 0usize;
while i < caracteres.len() {
match sauter_chaine_ou_commentaire(caracteres, i) {
Some(fin) => {
let borne = fin.min(caracteres.len());
for m in masque.iter_mut().take(borne).skip(i) {
*m = false;
}
i = fin;
}
None => i += 1,
}
}
masque
}
/// Recherche la prochaine occurrence de `motif` à partir de `depart`, en
/// ignorant toute position où `masque` vaut false (chaîne/commentaire).
/// Fonction PURE.
fn trouver_prochaine_occurrence(
caracteres: &[char], masque: &[bool], depart: usize, motif: &str,
) -> Option<usize> {
let mut i = depart;
while i < caracteres.len() {
if masque[i] && correspond_a(motif, caracteres, i) {
return Some(i);
}
i += 1;
}
None
}
/// À partir d'une position de départ, avance jusqu'à l'accolade ouvrante
/// d'un bloc (en ignorant les parenthèses d'une éventuelle signature de
/// fonction, les chaînes et les commentaires), puis retourne la position
/// juste après l'accolade fermante correspondante. Fonction PURE — sert
/// aussi bien pour `mod nom { ... }` (aucune parenthèse) que pour
/// `fn nom(...) { ... }` (parenthèses de signature correctement ignorées).
fn trouver_fin_bloc(caracteres: &[char], masque: &[bool], depart: usize) -> Option<usize> {
let mut i = depart;
let mut profondeur_parenthese = 0i32;
let mut position_ouverture: Option<usize> = None;
while i < caracteres.len() {
if !masque[i] {
i += 1;
continue;
}
match caracteres[i] {
'(' => profondeur_parenthese += 1,
')' => profondeur_parenthese -= 1,
';' if profondeur_parenthese == 0 => return None,
'{' if profondeur_parenthese == 0 => {
position_ouverture = Some(i);
break;
}
_ => {}
}
i += 1;
}
let debut_corps = match position_ouverture {
Some(p) => p + 1,
None => return None,
};
let mut profondeur_accolade = 1i32;
let mut k = debut_corps;
while k < caracteres.len() {
if !masque[k] {
k += 1;
continue;
}
match caracteres[k] {
'{' => profondeur_accolade += 1,
'}' => {
profondeur_accolade -= 1;
if profondeur_accolade == 0 {
return Some(k + 1);
}
}
_ => {}
}
k += 1;
}
None
}
/// Repère toutes les zones "code de test" du fichier : le corps d'un module
/// `#[cfg(test)] mod nom { ... }`, et le corps de chaque fonction annotée
/// `#[test]` ou `#[tokio::test]` (utile si de telles fonctions existent en
/// dehors d'un module `#[cfg(test)]`, ou en complément). Fonction PURE,
/// retourne une liste d'intervalles `[debut, fin)` en position caractère.
fn reperer_zones_test(caracteres: &[char], masque: &[bool]) -> Vec<(usize, usize)> {
let mut zones = Vec::new();
for attribut in ["#[cfg(test)]", "#[test]", "#[tokio::test]"] {
let mut curseur = 0usize;
while let Some(pos_attribut) = trouver_prochaine_occurrence(caracteres, masque, curseur, attribut) {
let apres_attribut = pos_attribut + attribut.chars().count();
let mot_ancre = if attribut == "#[cfg(test)]" { "mod " } else { "fn " };
match trouver_prochaine_occurrence(caracteres, masque, apres_attribut, mot_ancre) {
Some(pos_ancre) => {
let debut_recherche_bloc = pos_ancre + mot_ancre.chars().count();
match trouver_fin_bloc(caracteres, masque, debut_recherche_bloc) {
Some(fin_bloc) => {
zones.push((pos_attribut, fin_bloc));
curseur = fin_bloc;
}
None => curseur = apres_attribut,
}
}
None => curseur = apres_attribut,
}
}
}
zones
}
fn position_dans_zone_test(position: usize, zones: &[(usize, usize)]) -> bool {
zones.iter().any(|(debut, fin)| position >= *debut && position < *fin)
}
fn ligne_de_position(caracteres: &[char], position: usize) -> usize {
1 + caracteres[..position].iter().filter(|c| **c == '\n').count()
}
fn extraire_ligne(caracteres: &[char], position: usize) -> String {
let mut debut = position;
while debut > 0 && caracteres[debut - 1] != '\n' {
debut -= 1;
}
let mut fin = position;
while fin < caracteres.len() && caracteres[fin] != '\n' {
fin += 1;
}
let mut extrait: String = caracteres[debut..fin].iter().collect();
let extrait_trim = extrait.trim().to_string();
extrait = extrait_trim;
if extrait.len() > 120 {
extrait.truncate(117);
extrait.push_str("...");
}
extrait
}
/// Fonction PURE — cœur de la détection, opère sur le texte déjà en
/// mémoire (aucune I/O), testable directement sur des chaînes de caractères.
fn detecter_points_panique(contenu: &str) -> Vec<(usize, String, String)> {
let caracteres: Vec<char> = contenu.chars().collect();
let masque = construire_masque_code(&caracteres);
let zones_test = reperer_zones_test(&caracteres, &masque);
let mut resultats = Vec::new();
for motif in MOTIFS_PANIQUE {
let mut curseur = 0usize;
while let Some(position) = trouver_prochaine_occurrence(&caracteres, &masque, curseur, motif) {
curseur = position + 1;
if position_dans_zone_test(position, &zones_test) {
continue;
}
let extrait = extraire_ligne(&caracteres, position);
if extrait.contains(MARQUEUR_EXEMPTION_LIGNE) {
continue;
}
let ligne = ligne_de_position(&caracteres, position);
resultats.push((ligne, (*motif).to_string(), extrait));
}
}
resultats
}
fn scanner_fichier(chemin: &Path) -> Result<Vec<Trouvaille>, Erreur> {
let contenu = match fs::read_to_string(chemin) {
Ok(c) => c,
Err(e) => {
return Err(Erreur::LectureFichier {
chemin: chemin.display().to_string(),
source: e,
});
}
};
let fichier_str = chemin.display().to_string();
let trouvailles = detecter_points_panique(&contenu)
.into_iter()
.map(|(ligne, motif_declencheur, extrait)| Trouvaille {
fichier: fichier_str.clone(),
ligne,
extrait,
motif_declencheur,
})
.collect();
Ok(trouvailles)
}
fn iter_fichiers_rust(racine: &Path, resultats: &mut Vec<PathBuf>) {
let entrees = match fs::read_dir(racine) {
Ok(e) => e,
Err(e) => {
eprintln!("Avertissement : lecture dossier {} echouee : {e}", racine.display());
return;
}
};
for entree_res in entrees {
let entree = match entree_res {
Ok(e) => e,
Err(e) => {
eprintln!("Avertissement : entree de dossier illisible : {e}");
continue;
}
};
let chemin = entree.path();
let nom = match chemin.file_name() {
Some(n) => match n.to_str() {
Some(s) => s,
None => continue,
},
None => continue,
};
if DOSSIERS_IGNORES.contains(&nom) {
continue;
}
let type_fichier = match entree.file_type() {
Ok(ft) => ft,
Err(e) => {
eprintln!("Avertissement : type de {} inconnu : {e}", chemin.display());
continue; // §27-ok — intentionnel : ce fichier est ignoré, le parcours des autres continue
}
};
let est_rs = match chemin.extension() {
Some(ext) => match ext.to_str() {
Some(s) => s == "rs",
None => false, // §5-ok — extension non UTF-8 valide : jamais un fichier .rs légitime
},
None => false, // §5-ok — pas d'extension du tout : jamais un fichier .rs
};
if type_fichier.is_dir() {
iter_fichiers_rust(&chemin, resultats);
} else if est_rs {
resultats.push(chemin);
}
}
}
fn generer_rapport(trouvailles: &[Trouvaille], nb_fichiers: usize) -> String {
let mut rapport = String::new();
rapport.push_str("# Rapport detecteur-panic-points\n\n");
rapport.push_str(&format!(
"{nb_fichiers} fichier(s) .rs examine(s) — {} signalement(s) au total.\n\n",
trouvailles.len()
));
if trouvailles.is_empty() {
rapport.push_str("Aucun point de panique detecte en dehors du code de test.\n");
return rapport;
}
for t in trouvailles {
rapport.push_str(&format!(
"- **{}** — {}:{}\n - Extrait : `{}`\n",
t.motif_declencheur, t.fichier, t.ligne, t.extrait
));
}
rapport
}
fn executer(dossier_cible: &str) -> Result<String, Erreur> {
let racine = Path::new(dossier_cible);
if !racine.is_dir() {
return Err(Erreur::DossierInvalide(dossier_cible.to_string()));
}
let mut fichiers = Vec::new();
iter_fichiers_rust(racine, &mut fichiers);
let mut toutes_trouvailles = Vec::new();
for chemin in &fichiers {
match scanner_fichier(chemin) {
Ok(mut t) => toutes_trouvailles.append(&mut t),
Err(e) => {
eprintln!("Avertissement : {e}");
// §27-ok — intentionnel : un fichier illisible est ignoré, le scan des autres continue
}
}
}
Ok(generer_rapport(&toutes_trouvailles, fichiers.len()))
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let dossier_cible = match args.get(1) {
Some(d) => d.clone(),
None => {
eprintln!("Erreur : {}", Erreur::ArgumentManquant);
eprintln!("Usage : detecteur-panic-points <dossier_cible>");
std::process::exit(1);
}
};
match executer(&dossier_cible) {
Ok(rapport) => println!("{rapport}"),
Err(e) => {
eprintln!("Erreur : {e}");
std::process::exit(1); // §27-ok — std::process::exit diverge, aucune exécution ne suit
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn t_detecte_unwrap_hors_test() {
let resultats = detecter_points_panique("fn f() {\n let x = maybe().unwrap();\n}\n");
assert_eq!(resultats.len(), 1);
assert_eq!(resultats[0].1, ".unwrap()");
assert_eq!(resultats[0].0, 2);
}
#[test]
fn t_detecte_expect_hors_test() {
let resultats = detecter_points_panique("fn f() {\n let x = maybe().expect(\"msg\");\n}\n");
assert_eq!(resultats.len(), 1);
assert_eq!(resultats[0].1, ".expect(");
}
#[test]
fn t_detecte_panic_macro() {
let resultats = detecter_points_panique("fn f() {\n panic!(\"boom\");\n}\n");
assert_eq!(resultats.len(), 1);
assert_eq!(resultats[0].1, "panic!(");
}
#[test]
fn t_detecte_unreachable_todo_unimplemented() {
let source = "fn a() { unreachable!(); }\nfn b() { todo!(); }\nfn c() { unimplemented!(); }\n";
let resultats = detecter_points_panique(source);
assert_eq!(resultats.len(), 3);
}
#[test]
fn t_ignore_dans_module_cfg_test() {
let source = "fn prod() {}\n#[cfg(test)]\nmod tests {\n #[test]\n fn t() {\n let x = maybe().unwrap();\n assert!(x);\n }\n}\n";
let resultats = detecter_points_panique(source);
assert!(resultats.is_empty(), "unwrap() dans #[cfg(test)] mod ne doit jamais etre signale");
}
#[test]
fn t_ignore_fonction_test_isolee_hors_cfg_test_mod() {
let source = "fn prod() {}\n#[tokio::test]\nasync fn t_isole() {\n let x = maybe().await.unwrap();\n}\n";
let resultats = detecter_points_panique(source);
assert!(resultats.is_empty(), "#[tokio::test] hors mod cfg(test) doit aussi etre exempte");
}
#[test]
fn t_ignore_chaine_brute_avec_faux_unwrap() {
let source = "fn f() {\n let doc = r#\"exemple: maybe().unwrap()\"#;\n let _ = doc;\n}\n";
let resultats = detecter_points_panique(source);
assert!(resultats.is_empty(), "un exemple dans une chaine brute ne doit jamais etre signale");
}
#[test]
fn t_ignore_commentaire() {
let source = "fn f() {\n // ancien code : maybe().unwrap();\n let x = 1;\n}\n";
let resultats = detecter_points_panique(source);
assert!(resultats.is_empty());
}
#[test]
fn t_ignore_marqueur_exemption() {
let source = "fn f() {\n let x = maybe().unwrap(); // panic-ok\n}\n";
let resultats = detecter_points_panique(source);
assert!(resultats.is_empty());
}
#[test]
fn t_zone_test_ne_masque_pas_le_code_reel_suivant() {
let source = "#[cfg(test)]\nmod tests {\n #[test]\n fn t() {\n maybe().unwrap();\n }\n}\nfn prod() {\n maybe().unwrap();\n}\n";
let resultats = detecter_points_panique(source);
assert_eq!(resultats.len(), 1, "seul le vrai code de production doit etre signale");
assert_eq!(resultats[0].0, 9);
}
#[test]
fn t_generer_rapport_01_vide() {
let rapport = generer_rapport(&[], 4);
assert!(rapport.contains("4 fichier"));
assert!(rapport.contains("0 signalement"));
assert!(rapport.contains("Aucun point de panique"));
}
#[test]
fn t_generer_rapport_02_avec_trouvailles() {
let trouvailles = vec![Trouvaille {
fichier: "exemple.rs".to_string(),
ligne: 7,
extrait: "maybe().unwrap();".to_string(),
motif_declencheur: ".unwrap()".to_string(),
}];
let rapport = generer_rapport(&trouvailles, 1);
assert!(rapport.contains("exemple.rs:7"));
assert!(rapport.contains(".unwrap()"));
}
#[test]
fn t_masque_code_exclut_chaine_et_commentaire() {
let caracteres: Vec<char> = "let s = \"a{b\"; // c{d".chars().collect();
let masque = construire_masque_code(&caracteres);
match caracteres.iter().position(|c| *c == 'a') {
Some(position_a) => assert!(!masque[position_a], "interieur de chaine doit etre masque"),
None => assert!(false, "fixture invalide : aucun 'a' trouve"),
}
match caracteres.iter().rposition(|c| *c == 'c') {
Some(position_c) => assert!(!masque[position_c], "interieur de commentaire doit etre masque"),
None => assert!(false, "fixture invalide : aucun 'c' trouve"),
}
}
}