Skip to main content

mnemo/
mdfmt.rs

1//! Helpers de rendu Markdown et d'affichage partagés.
2//!
3//! Ces fonctions encodent du texte arbitraire (commandes shell, chemins) sans
4//! jamais casser la structure d'un document Markdown : elles échappent les
5//! pipes des tableaux, neutralisent les retours à la ligne et choisissent des
6//! clôtures de code plus longues que toute suite de backticks interne. Elles
7//! sont réutilisées par `mnemo session` et `mnemo project` afin de garantir un
8//! rendu cohérent et robuste.
9
10/// Plus longue suite consécutive de backticks dans `s`.
11pub fn longest_backtick_run(s: &str) -> usize {
12    let mut max = 0;
13    let mut cur = 0;
14    for ch in s.chars() {
15        if ch == '`' {
16            cur += 1;
17            max = max.max(cur);
18        } else {
19            cur = 0;
20        }
21    }
22    max
23}
24
25/// Encadre `commands` dans un bloc de code Markdown, en choisissant une clôture
26/// plus longue que toute suite de backticks présente, pour ne jamais casser le
27/// bloc.
28pub fn md_code_block(commands: &[String]) -> String {
29    let max_run = commands
30        .iter()
31        .map(|c| longest_backtick_run(c))
32        .max()
33        .unwrap_or(0);
34    let fence = "`".repeat(max_run.max(2) + 1);
35    let mut out = String::new();
36    out.push_str(&fence);
37    out.push_str("bash\n");
38    for c in commands {
39        out.push_str(c);
40        out.push('\n');
41    }
42    out.push_str(&fence);
43    out.push('\n');
44    out
45}
46
47/// Rend une chaîne en code en ligne Markdown, robuste aux backticks internes.
48pub fn md_inline_code(s: &str) -> String {
49    let ticks = "`".repeat(longest_backtick_run(s) + 1);
50    let pad = if s.starts_with('`') || s.ends_with('`') {
51        " "
52    } else {
53        ""
54    };
55    format!("{ticks}{pad}{s}{pad}{ticks}")
56}
57
58/// Échappe une cellule de tableau Markdown en texte simple (pipes et retours).
59pub fn md_table_cell_text(s: &str) -> String {
60    s.replace('|', "\\|").replace(['\n', '\r'], " ")
61}
62
63/// Rend une commande dans une cellule de tableau Markdown, en code en ligne,
64/// sans casser la structure du tableau.
65pub fn md_table_cell_code(s: &str) -> String {
66    let oneline = s.replace(['\n', '\r'], " ");
67    let escaped = oneline.replace('|', "\\|");
68    md_inline_code(&escaped)
69}
70
71/// Raccourcit un chemin sous le répertoire personnel en `~/...`.
72pub fn display_home(path: &str) -> String {
73    if let Some(home) = dirs::home_dir() {
74        if let Some(home_str) = home.to_str() {
75            if let Some(rest) = path.strip_prefix(home_str) {
76                let rest = rest.trim_start_matches('/');
77                if rest.is_empty() {
78                    return "~".to_string();
79                }
80                return format!("~/{rest}");
81            }
82        }
83    }
84    path.to_string()
85}
86
87/// Affiche une option de chemin avec raccourci `~`, ou `-` si absente.
88pub fn opt_home(value: &Option<String>) -> String {
89    value
90        .as_deref()
91        .filter(|s| !s.is_empty())
92        .map(display_home)
93        .unwrap_or_else(|| "-".to_string())
94}
95
96/// Affiche une option textuelle, ou `-` si absente.
97pub fn opt(value: &Option<String>) -> String {
98    value
99        .as_deref()
100        .filter(|s| !s.is_empty())
101        .unwrap_or("-")
102        .to_string()
103}
104
105/// Partie horaire (`HH:MM:SS`) d'un horodatage `YYYY-MM-DD HH:MM:SS`.
106pub fn time_part(created_at: &str) -> &str {
107    created_at.split_whitespace().nth(1).unwrap_or(created_at)
108}
109
110/// Tronque un horodatage à la minute (`YYYY-MM-DD HH:MM`).
111pub fn short_datetime(created_at: &str) -> &str {
112    created_at.get(..16).unwrap_or(created_at)
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn inline_code_protege_les_backticks() {
121        assert_eq!(md_inline_code("ls"), "`ls`");
122        assert_eq!(md_inline_code("echo `date`"), "`` echo `date` ``");
123    }
124
125    #[test]
126    fn code_block_choisit_une_cloture_assez_longue() {
127        let block = md_code_block(&["echo ```x```".to_string()]);
128        assert!(block.starts_with("````bash\n"));
129        assert!(block.trim_end().ends_with("````"));
130    }
131
132    #[test]
133    fn table_cell_echappe_les_pipes() {
134        let cell = md_table_cell_code("grep -E 'a|b'");
135        assert!(cell.contains("\\|"));
136        assert!(!cell.contains("a|b"));
137    }
138
139    #[test]
140    fn horaire_et_date_courte() {
141        assert_eq!(time_part("2026-06-23 10:12:01"), "10:12:01");
142        assert_eq!(short_datetime("2026-06-23 10:12:01"), "2026-06-23 10:12");
143    }
144}