tdd-rust

Par rtk-ai · rtk

Workflow TDD pour le développement de filtres RTK. Red-Green-Refactor avec les idiomes Rust. Fixtures réelles, assertions sur les économies de tokens, tests de snapshot avec insta. Déclenchement automatique à chaque nouvelle implémentation de filtre.

npx skills add https://github.com/rtk-ai/rtk --skill tdd-rust

Workflow TDD RTK

Imposez Red-Green-Refactor pour tout développement de filtre RTK.

La boucle

1. RED   — Écrire un test échouant avec fixture réelle
2. GREEN — Implémenter le code minimum pour passer
3. REFACTOR — Nettoyer, vérifier que c'est toujours bon
4. SAVINGS — Vérifier ≥60% de réduction de tokens
5. SNAPSHOT — Verrouiller le format de sortie avec insta

Étape 1 : Fixture réelle en premier

Ne jamais écrire de données de test synthétiques. Capturez la vraie sortie de commande :

# Capturer la vraie sortie de la commande actuelle
git log -20 > tests/fixtures/git_log_raw.txt
cargo test 2>&1 > tests/fixtures/cargo_test_raw.txt
cargo clippy 2>&1 > tests/fixtures/cargo_clippy_raw.txt
gh pr view 42 > tests/fixtures/gh_pr_view_raw.txt

# Pour les commandes avec codes ANSI — capturer tel quel
script -q /dev/null cargo test 2>&1 > tests/fixtures/cargo_test_ansi_raw.txt

Nommage fixture : tests/fixtures/<commande>_raw.txt

Étape 2 : Écrire le test (Red)

#[cfg(test)]
mod tests {
    use super::*;
    use insta::assert_snapshot;

    fn count_tokens(s: &str) -> usize {
        s.split_whitespace().count()
    }

    // Test 1 : Format de sortie (snapshot)
    #[test]
    fn test_filter_output_format() {
        let input = include_str!("../tests/fixtures/mycmd_raw.txt");
        let output = filter_mycmd(input).expect("filter should not fail");
        assert_snapshot!(output);
    }

    // Test 2 : Économies de tokens ≥60%
    #[test]
    fn test_token_savings() {
        let input = include_str!("../tests/fixtures/mycmd_raw.txt");
        let output = filter_mycmd(input).expect("filter should not fail");

        let input_tokens = count_tokens(input);
        let output_tokens = count_tokens(&output);
        let savings = 100.0 * (1.0 - output_tokens as f64 / input_tokens as f64);

        assert!(
            savings >= 60.0,
            "Expected ≥60% token savings, got {:.1}% ({} → {} tokens)",
            savings, input_tokens, output_tokens
        );
    }

    // Test 3 : Cas limites
    #[test]
    fn test_empty_input() {
        let result = filter_mycmd("");
        assert!(result.is_ok());
        // Entrée vide = sortie vide OU passthrough, jamais panic
    }

    #[test]
    fn test_malformed_input() {
        let result = filter_mycmd("not valid command output\nrandom text\n");
        // Ne doit pas panicker — soit filtrer au mieux soit retourner l'entrée inchangée
        assert!(result.is_ok());
    }
}

Lancer : cargo test → devrait échouer (fonction n'existe pas encore).

Étape 3 : Implémentation minimale (Green)

// src/mycmd_cmd.rs

use anyhow::{Context, Result};
use lazy_static::lazy_static;
use regex::Regex;

lazy_static! {
    static ref ERROR_RE: Regex = Regex::new(r"^error").unwrap();
}

pub fn filter_mycmd(input: &str) -> Result<String> {
    if input.is_empty() {
        return Ok(String::new());
    }

    let filtered: Vec<&str> = input.lines()
        .filter(|line| ERROR_RE.is_match(line))
        .collect();

    Ok(filtered.join("\n"))
}

Lancer : cargo test → vert.

Étape 4 : Accepter le snapshot

# La première exécution crée le snapshot
cargo test test_filter_output_format

# Vérifier ce qui a été capturé
cargo insta review
# Appuyer sur 'a' pour accepter

# Snapshot sauvegardé dans src/snapshots/mycmd_cmd__tests__test_filter_output_format.snap

Étape 5 : Câbler vers main.rs (Intégration)

// src/main.rs
mod mycmd_cmd;

#[derive(Subcommand)]
pub enum Commands {
    // ... commandes existantes ...
    Mycmd(MycmdArgs),
}

// Dans match :
Commands::Mycmd(args) => mycmd_cmd::run(args),
// src/mycmd_cmd.rs — ajouter la fonction run()
pub fn run(args: MycmdArgs) -> Result<()> {
    let output = execute_command("mycmd", &args.to_vec())
        .context("Failed to execute mycmd")?;

    let filtered = filter_mycmd(&output.stdout)
        .unwrap_or_else(|e| {
            eprintln!("rtk: filter warning: {}", e);
            output.stdout.clone()
        });

    tracking::record("mycmd", &output.stdout, &filtered)?;
    print!("{}", filtered);

    if !output.status.success() {
        std::process::exit(output.status.code().unwrap_or(1));
    }
    Ok(())
}

Étape 6 : Qualité Gate

cargo fmt --all && cargo clippy --all-targets && cargo test

Les 3 doivent passer. Zéro avertissement clippy.

Modèle Arrange-Act-Assert

#[test]
fn test_filters_only_errors() {
    // Arrange
    let input = "info: starting build\nerror[E0001]: undefined\nwarning: unused\n";

    // Act
    let output = filter_mycmd(input).expect("should succeed");

    // Assert
    assert!(output.contains("error[E0001]"), "Should keep error lines");
    assert!(!output.contains("info:"), "Should drop info lines");
    assert!(!output.contains("warning:"), "Should drop warning lines");
}

Modèles de test spécifiques à RTK

Tester le retrait ANSI

#[test]
fn test_strips_ansi_codes() {
    let input = "\x1b[32mSuccess\x1b[0m\n\x1b[31merror: failed\x1b[0m\n";
    let output = filter_mycmd(input).expect("should succeed");
    assert!(!output.contains("\x1b["), "ANSI codes should be stripped");
    assert!(output.contains("error: failed"), "Content should be preserved");
}

Tester le comportement de secours

#[test]
fn test_filter_handles_unexpected_format() {
    // Lui donner quelque chose de complètement inattendu
    let input = "completely unexpected\x00binary\xff data";
    // Ne doit pas panicker — retourne Ok() avec soit vide soit passthrough
    let result = filter_mycmd(input);
    assert!(result.is_ok(), "Filter must not panic on unexpected input");
}

Tester les économies à différentes tailles

#[test]
fn test_savings_large_output() {
    // 1000 lignes de fixture → doit toujours atteindre ≥60%
    let large_input: String = (0..1000)
        .map(|i| format!("info: processing item {}\n", i))
        .collect();
    let output = filter_mycmd(&large_input).expect("should succeed");

    let savings = 100.0 * (1.0 - count_tokens(&output) as f64 / count_tokens(&large_input) as f64);
    assert!(savings >= 60.0, "Large output savings: {:.1}%", savings);
}

Ce que « Fini » ressemble à

Checklist avant de continuer :

  • [ ] tests/fixtures/<cmd>_raw.txt — vraie sortie de commande
  • [ ] Fonction filter_<cmd>() retourne Result<String>
  • [ ] Test snapshot passe et accepté via cargo insta review
  • [ ] Test économies de tokens : ≥60% vérifié
  • [ ] Test entrée vide : pas de panic
  • [ ] Test entrée malformée : pas de panic
  • [ ] Fonction run() avec modèle de secours
  • [ ] Enregistrée dans l'enum Commands de main.rs
  • [ ] cargo fmt --all && cargo clippy --all-targets && cargo test — tout vert

Ne jamais faire ça

// ❌ Donnée de fixture synthétique
let input = "fake error: something went wrong";  // Pas vraie sortie cargo

// ❌ Test d'économies manquant
#[test]
fn test_filter() {
    let output = filter_mycmd(input);
    assert!(!output.is_empty());  // Pas de vérification d'économies
}

// ❌ unwrap() en code production
let filtered = filter_mycmd(input).unwrap();  // Panic en prod

// ❌ Regex à l'intérieur de la fonction filtre
fn filter_mycmd(input: &str) -> Result<String> {
    let re = Regex::new(r"^error").unwrap();  // Recompile à chaque appel
    ...
}

Skills similaires