📄 main.rs 🔒 d628a228…9f4c5299 Se connecter pour télécharger ← Retour
// ⛔ 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"),
        }
    }
}
19.7 Ko BLAKE3 : d628a228…9f4c5299