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"); } }
GitRust