Skip to main content

mnemo/tui/
format.rs

1//! Helpers de formatage purs pour la TUI (sans dépendance au terminal).
2//!
3//! Ces fonctions sont isolées ici pour être testées unitairement : troncature
4//! des chemins et commandes, extraction de l'heure, libellé de contexte. Elles
5//! ne paniquent jamais, y compris sur des entrées vides, très longues ou
6//! multi-octets (UTF-8).
7
8use crate::db::CommandRecord;
9use crate::tui::app::last_segment;
10
11/// Caractère d'ellipse utilisé pour les troncatures.
12const ELLIPSIS: char = '…';
13
14/// Extrait l'heure `HH:MM` d'un horodatage `YYYY-MM-DD HH:MM:SS`.
15///
16/// Retombe sur la chaîne complète si le format est inattendu (jamais de panique).
17pub fn short_time(created_at: &str) -> &str {
18    // Position 11..16 correspond à `HH:MM` dans `YYYY-MM-DD HH:MM:SS`.
19    created_at.get(11..16).unwrap_or(created_at)
20}
21
22/// Tronque `s` à `max` caractères en ajoutant une ellipse finale si nécessaire.
23///
24/// `max == 0` renvoie une chaîne vide. La troncature se fait sur les caractères
25/// (pas les octets) pour rester correcte en UTF-8.
26pub fn truncate_end(s: &str, max: usize) -> String {
27    if max == 0 {
28        return String::new();
29    }
30    let count = s.chars().count();
31    if count <= max {
32        return s.to_string();
33    }
34    if max == 1 {
35        return ELLIPSIS.to_string();
36    }
37    let mut out: String = s.chars().take(max - 1).collect();
38    out.push(ELLIPSIS);
39    out
40}
41
42/// Tronque `s` en conservant le début et la fin, avec une ellipse au milieu.
43///
44/// Utile pour les chemins (`/home/.../mnemo/backups`). `max == 0` renvoie une
45/// chaîne vide ; en deçà de 5 caractères on retombe sur une troncature de fin.
46pub fn truncate_middle(s: &str, max: usize) -> String {
47    let count = s.chars().count();
48    if count <= max {
49        return s.to_string();
50    }
51    if max < 5 {
52        return truncate_end(s, max);
53    }
54    // Réserve un caractère pour l'ellipse, répartit le reste tête/queue.
55    let keep = max - 1;
56    let head = keep.div_ceil(2);
57    let tail = keep - head;
58    let chars: Vec<char> = s.chars().collect();
59    let mut out: String = chars[..head].iter().collect();
60    out.push(ELLIPSIS);
61    out.extend(chars[count - tail..].iter());
62    out
63}
64
65/// Libellé de contexte d'une commande : nom du projet Git si disponible, sinon
66/// dernier segment du répertoire de travail, sinon `-`.
67pub fn context_label(record: &CommandRecord) -> String {
68    if let Some(root) = &record.git_root {
69        if !root.is_empty() {
70            return last_segment(root).to_string();
71        }
72    }
73    if let Some(cwd) = &record.cwd {
74        if !cwd.is_empty() {
75            return last_segment(cwd).to_string();
76        }
77    }
78    "-".to_string()
79}
80
81/// Statut court (`SUCCESS` / `FAILED` / `-`) à partir du code de sortie.
82pub fn status_text(exit_code: Option<i64>) -> &'static str {
83    match exit_code {
84        Some(0) => "SUCCESS",
85        Some(_) => "FAILED",
86        None => "-",
87    }
88}
89
90/// Symbole de statut (`✓` / `✗` / ` `) à partir du code de sortie.
91pub fn status_symbol(exit_code: Option<i64>) -> &'static str {
92    match exit_code {
93        Some(0) => "✓",
94        Some(_) => "✗",
95        None => "·",
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    fn rec(command: &str) -> CommandRecord {
104        CommandRecord {
105            id: 1,
106            command: command.to_string(),
107            cwd: None,
108            shell: None,
109            hostname: None,
110            exit_code: None,
111            created_at: "2026-06-14 17:29:11".to_string(),
112            git_root: None,
113            git_branch: None,
114            git_remote: None,
115            session_id: None,
116        }
117    }
118
119    #[test]
120    fn short_time_extrait_hh_mm() {
121        assert_eq!(short_time("2026-06-14 17:29:11"), "17:29");
122    }
123
124    #[test]
125    fn short_time_tolere_format_inattendu() {
126        assert_eq!(short_time("court"), "court");
127        assert_eq!(short_time(""), "");
128    }
129
130    #[test]
131    fn truncate_end_court_inchange() {
132        assert_eq!(truncate_end("git push", 20), "git push");
133    }
134
135    #[test]
136    fn truncate_end_ajoute_ellipse() {
137        let out = truncate_end("git push origin main", 8);
138        assert_eq!(out.chars().count(), 8);
139        assert!(out.ends_with('…'));
140    }
141
142    #[test]
143    fn truncate_end_cas_limites() {
144        assert_eq!(truncate_end("abc", 0), "");
145        assert_eq!(truncate_end("abc", 1), "…");
146    }
147
148    #[test]
149    fn truncate_end_ne_panique_pas_sur_utf8() {
150        let s = "éàçü emojis 🚀🚀🚀 accentués";
151        let out = truncate_end(s, 5);
152        assert_eq!(out.chars().count(), 5);
153    }
154
155    #[test]
156    fn truncate_middle_conserve_tete_et_queue() {
157        let path = "/home/killian/projects/mnemo/backups";
158        let out = truncate_middle(path, 20);
159        assert_eq!(out.chars().count(), 20);
160        assert!(out.contains('…'));
161        assert!(out.starts_with('/'));
162        assert!(out.ends_with('s'));
163    }
164
165    #[test]
166    fn truncate_middle_court_inchange() {
167        assert_eq!(truncate_middle("/tmp", 20), "/tmp");
168    }
169
170    #[test]
171    fn context_label_prefere_projet_git() {
172        let mut r = rec("cargo build");
173        r.git_root = Some("/home/killian/mnemo".to_string());
174        assert_eq!(context_label(&r), "mnemo");
175    }
176
177    #[test]
178    fn context_label_retombe_sur_dossier() {
179        let mut r = rec("ls");
180        r.cwd = Some("/var/log".to_string());
181        assert_eq!(context_label(&r), "log");
182    }
183
184    #[test]
185    fn context_label_sans_contexte() {
186        assert_eq!(context_label(&rec("ls")), "-");
187    }
188
189    #[test]
190    fn status_text_et_symbole() {
191        assert_eq!(status_text(Some(0)), "SUCCESS");
192        assert_eq!(status_text(Some(1)), "FAILED");
193        assert_eq!(status_text(None), "-");
194        assert_eq!(status_symbol(Some(0)), "✓");
195        assert_eq!(status_symbol(Some(2)), "✗");
196    }
197}