todo.rs 219 lignes · 6679 octets
//! Rendu / lecture / fusion du tableau de todo Markdown.

/// Une ligne du tableau de todo.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TodoRow {
    pub done: bool,
    pub functionality: String,
    pub screen: String,
    pub route: String,
    pub channel: String,
    pub protected: bool,
    pub methods: String,
    pub io: String,
}

const HEADER: &str =
    "| Statut | Fonctionnalité | Écran | Route | Canal | Protégé | Méthodes core | I/O |";
const SEP: &str = "|---|---|---|---|---|---|---|---|";

/// Rend les lignes en tableau Markdown (en-tête + séparateur + lignes).
pub fn render(rows: &[TodoRow]) -> String {
    let mut out = String::new();
    out.push_str(HEADER);
    out.push('\n');
    out.push_str(SEP);
    out.push('\n');
    for r in rows {
        out.push_str(&format!(
            "| {} | {} | {} | `{}` | {} | {} | {} | {} |\n",
            if r.done { "☑" } else { "☐" },
            r.functionality,
            r.screen,
            r.route,
            r.channel,
            if r.protected { "oui" } else { "non" },
            r.methods,
            r.io,
        ));
    }
    out
}

/// Extrait les lignes de données du premier bloc-tableau rencontré.
pub fn parse(content: &str) -> Vec<TodoRow> {
    let mut rows = Vec::new();
    for line in content.lines() {
        let line = line.trim();
        if !line.starts_with('|') {
            continue;
        }
        // ignorer en-tête et séparateur
        if line.starts_with("| Statut") || line.starts_with("|---") {
            continue;
        }
        let cells: Vec<&str> = line
            .trim_matches('|')
            .split('|')
            .map(|c| c.trim())
            .collect();
        if cells.len() != 8 {
            continue;
        }
        rows.push(TodoRow {
            done: cells[0].contains('☑'),
            functionality: cells[1].to_string(),
            screen: cells[2].to_string(),
            route: cells[3].trim_matches('`').to_string(),
            channel: cells[4].to_string(),
            protected: cells[5] == "oui",
            methods: cells[6].to_string(),
            io: cells[7].to_string(),
        });
    }
    rows
}

/// Clé d'identité d'une ligne (indépendante de la route, éditable).
fn key(r: &TodoRow) -> (&str, &str) {
    (r.functionality.as_str(), r.screen.as_str())
}

/// Produit les lignes pour l'ensemble `desired`, en conservant `done` et
/// `route` des lignes `existing` de même (fonctionnalité, écran).
pub fn merge(existing: &[TodoRow], desired: &[TodoRow]) -> Vec<TodoRow> {
    desired
        .iter()
        .map(|d| match existing.iter().find(|e| key(e) == key(d)) {
            Some(prev) => TodoRow {
                done: prev.done,
                route: prev.route.clone(),
                ..d.clone()
            },
            None => d.clone(),
        })
        .collect()
}

/// Remplace le premier bloc-tableau (`|`…) de `content` par le rendu de `rows`.
pub fn replace_table(content: &str, rows: &[TodoRow]) -> String {
    let lines: Vec<&str> = content.lines().collect();
    let start = lines.iter().position(|l| l.trim_start().starts_with('|'));
    let Some(start) = start else {
        // pas de tableau existant : on ajoute à la fin
        let mut out = content.to_string();
        if !out.ends_with('\n') {
            out.push('\n');
        }
        out.push_str(&render(rows));
        return out;
    };
    let mut end = start;
    while end < lines.len() && lines[end].trim_start().starts_with('|') {
        end += 1;
    }
    let mut out = String::new();
    for l in &lines[..start] {
        out.push_str(l);
        out.push('\n');
    }
    out.push_str(&render(rows));
    for l in &lines[end..] {
        out.push_str(l);
        out.push('\n');
    }
    out
}

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

    fn row() -> TodoRow {
        TodoRow {
            done: false,
            functionality: "TwoFactor".into(),
            screen: "TwoFactorSetup".into(),
            route: "/settings/2fa".into(),
            channel: "browser".into(),
            protected: true,
            methods: "TotpService".into(),
            io: "QR".into(),
        }
    }

    #[test]
    fn render_contient_entete_et_ligne() {
        let out = render(&[row()]);
        assert!(out.contains(HEADER));
        assert!(out.contains(SEP));
        assert!(out.contains("| ☐ | TwoFactor | TwoFactorSetup | `/settings/2fa` | browser | oui | TotpService | QR |"));
    }

    #[test]
    fn render_coche_les_faits() {
        let mut r = row();
        r.done = true;
        assert!(render(&[r]).contains("| ☑ |"));
    }

    #[test]
    fn parse_round_trip() {
        let mut a = row();
        a.done = true;
        let b = TodoRow {
            screen: "SessionsList".into(),
            route: "/settings/sessions".into(),
            ..row()
        };
        let rendered = render(&[a.clone(), b.clone()]);
        let parsed = parse(&rendered);
        assert_eq!(parsed, vec![a, b]);
    }

    #[test]
    fn parse_ignore_hors_tableau() {
        let md = format!("# Titre\n\ntexte\n\n{}", render(&[row()]));
        assert_eq!(parse(&md).len(), 1);
    }

    #[test]
    fn merge_preserve_statut_et_route() {
        let mut existing = row();
        existing.done = true;
        existing.route = "/custom/2fa".into(); // route éditée par le dev
        // "desired" = catalogue (route suggérée par défaut), même clé (func+screen)
        let desired = row();
        let merged = merge(&[existing.clone()], std::slice::from_ref(&desired));
        assert_eq!(merged.len(), 1);
        assert!(merged[0].done, "statut conservé");
        assert_eq!(merged[0].route, "/custom/2fa", "route éditée conservée");
    }

    #[test]
    fn merge_ajoute_les_nouveaux() {
        let desired_new = TodoRow {
            functionality: "Teams".into(),
            screen: "TeamsManagement".into(),
            ..row()
        };
        let merged = merge(&[], &[row(), desired_new.clone()]);
        assert_eq!(merged.len(), 2);
        assert!(
            merged
                .iter()
                .any(|r| r.screen == "TeamsManagement" && !r.done)
        );
    }

    #[test]
    fn replace_table_remplace_le_bloc() {
        let content = format!("# Titre\n\n## Écrans\n\n{}\n(fin)\n", render(&[row()]));
        let mut r = row();
        r.done = true;
        let out = replace_table(&content, &[r]);
        assert!(out.contains("# Titre"), "en-tête préservé");
        assert!(out.contains("(fin)"), "suffixe préservé");
        assert!(out.contains("| ☑ |"), "tableau mis à jour");
    }
}