Skip to main content

mnemo/
shell.rs

1/// Snippet d'intégration Bash à ajouter dans `~/.bashrc`.
2///
3/// - `__mnemo_record` est branché sur `PROMPT_COMMAND` et enregistre la
4///   dernière commande exécutée (avec son code de sortie et le répertoire).
5/// - La commande `mnemo` elle-même n'est jamais enregistrée.
6/// - `Ctrl+R` est remappé pour ouvrir la recherche TUI de mnemo.
7pub fn bashrc_snippet() -> String {
8    SNIPPET.to_string()
9}
10
11/// Marqueurs externes encadrant le bloc ajouté par l'installateur et
12/// `mnemo doctor --fix` (identiques à ceux de `scripts/lib/bashrc.sh`).
13pub const BLOCK_BEGIN: &str = "# >>> mnemo init >>>";
14pub const BLOCK_END: &str = "# <<< mnemo init <<<";
15
16/// Marqueur interne du snippet lui-même, présent dans tout bloc mnemo.
17const SNIPPET_BEGIN: &str = "# >>> mnemo >>>";
18
19/// Préfixe du marqueur de version interne au bloc. Permet de distinguer un bloc
20/// à jour d'un bloc « legacy » à mettre à niveau (`mnemo shell upgrade`).
21const VERSION_MARKER: &str = "# mnemo shell integration version:";
22
23/// Version courante du bloc d'intégration Bash. À incrémenter lorsqu'une
24/// évolution du snippet doit être propagée aux installations existantes (ici :
25/// capture de `MNEMO_SESSION_ID`, requise par `mnemo session`).
26pub const SNIPPET_VERSION: u32 = 2;
27
28/// Bloc complet à écrire dans le `.bashrc` (marqueurs externes + snippet).
29pub fn wrapped_block() -> String {
30    format!("{BLOCK_BEGIN}\n{}{BLOCK_END}\n", bashrc_snippet())
31}
32
33/// Indique si un contenu de `.bashrc` contient déjà l'intégration mnemo.
34pub fn has_block(content: &str) -> bool {
35    content.contains(SNIPPET_BEGIN) || content.contains("__mnemo_record")
36}
37
38/// Nombre de blocs mnemo détectés (sert à repérer les doublons).
39pub fn count_blocks(content: &str) -> usize {
40    content.matches(SNIPPET_BEGIN).count()
41}
42
43/// Indique si le bind `Ctrl+R` de mnemo est présent.
44pub fn has_ctrl_r_bind(content: &str) -> bool {
45    content.contains("__mnemo_search") || content.contains("\\C-r")
46}
47
48/// État du bloc d'intégration mnemo présent dans un `.bashrc`.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum BlockState {
51    /// Aucun bloc mnemo n'est installé.
52    Absent,
53    /// Bloc présent mais obsolète (ne capture pas `MNEMO_SESSION_ID`).
54    Legacy,
55    /// Bloc présent et à jour.
56    Current,
57}
58
59/// Lit la version déclarée par le bloc mnemo, si le marqueur est présent.
60pub fn block_version(content: &str) -> Option<u32> {
61    content
62        .lines()
63        .find_map(|line| line.trim().strip_prefix(VERSION_MARKER))
64        .and_then(|rest| rest.trim().parse().ok())
65}
66
67/// Détermine l'état du bloc mnemo présent dans `content`.
68///
69/// Un bloc est à jour s'il déclare une version au moins égale à
70/// [`SNIPPET_VERSION`]. Pour les blocs sans marqueur (antérieurs à son
71/// introduction), la présence de `MNEMO_SESSION_ID` sert de signal : c'est la
72/// capacité requise par `mnemo session`.
73pub fn block_state(content: &str) -> BlockState {
74    if !has_block(content) {
75        return BlockState::Absent;
76    }
77    let up_to_date = match block_version(content) {
78        Some(version) => version >= SNIPPET_VERSION,
79        None => content.contains("MNEMO_SESSION_ID"),
80    };
81    if up_to_date {
82        BlockState::Current
83    } else {
84        BlockState::Legacy
85    }
86}
87
88/// Horodatage compact `YYYYMMDD-HHMMSS` pour les noms de sauvegarde.
89fn compact_now() -> String {
90    let ts = crate::db::now_timestamp(); // "YYYY-MM-DD HH:MM:SS"
91    let date = ts.get(0..10).unwrap_or("").replace('-', "");
92    let time = ts.get(11..19).unwrap_or("").replace(':', "");
93    format!("{date}-{time}")
94}
95
96/// Ajoute le bloc mnemo au `.bashrc` s'il est absent, après sauvegarde.
97///
98/// - Idempotent : ne fait rien (et ne crée pas de sauvegarde) si le bloc est
99///   déjà présent.
100/// - Crée une sauvegarde `<bashrc>.mnemo.bak.YYYYMMDD-HHMMSS` si le fichier
101///   existe déjà.
102///
103/// Retourne `Ok(true)` si le bloc a été ajouté, `Ok(false)` s'il existait déjà.
104pub fn install_block(bashrc: &std::path::Path) -> anyhow::Result<bool> {
105    let existing = std::fs::read_to_string(bashrc).unwrap_or_default();
106    if has_block(&existing) {
107        return Ok(false);
108    }
109
110    if bashrc.exists() {
111        let backup = bashrc.with_file_name(format!(".bashrc.mnemo.bak.{}", compact_now()));
112        std::fs::copy(bashrc, &backup)?;
113    }
114
115    let mut content = existing;
116    if !content.is_empty() && !content.ends_with('\n') {
117        content.push('\n');
118    }
119    content.push_str(&wrapped_block());
120    std::fs::write(bashrc, content)?;
121    Ok(true)
122}
123
124/// Résultat d'une réparation du bloc `.bashrc` par `mnemo doctor --fix`.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum BlockRepair {
127    /// Bloc ajouté (il était absent).
128    Created,
129    /// Doublons supprimés, un seul bloc propre conservé.
130    Deduplicated,
131    /// Bloc régénéré pour restaurer le raccourci `Ctrl+R`.
132    CtrlRRestored,
133    /// Bloc obsolète régénéré vers la version courante.
134    Upgraded,
135    /// Rien à faire : un unique bloc complet était déjà présent.
136    AlreadyOk,
137}
138
139/// Retire tous les blocs mnemo encadrés par les marqueurs externes.
140///
141/// Fonction pure : ne touche pas au disque. Les lignes situées entre
142/// [`BLOCK_BEGIN`] et [`BLOCK_END`] (inclus) sont supprimées.
143pub fn strip_blocks(content: &str) -> String {
144    let mut out = String::new();
145    let mut in_block = false;
146    for line in content.lines() {
147        let trimmed = line.trim();
148        if trimmed == BLOCK_BEGIN {
149            in_block = true;
150            continue;
151        }
152        if trimmed == BLOCK_END {
153            in_block = false;
154            continue;
155        }
156        if !in_block {
157            out.push_str(line);
158            out.push('\n');
159        }
160    }
161    out
162}
163
164/// Répare le bloc mnemo du `.bashrc` : ajoute s'il manque, déduplique s'il est
165/// présent plusieurs fois, ou régénère un bloc complet si `Ctrl+R` a disparu.
166///
167/// Toujours précédé d'une sauvegarde `<bashrc>.mnemo.bak.YYYYMMDD-HHMMSS` avant
168/// toute modification. Non destructif vis-à-vis du reste du fichier.
169pub fn repair_block(bashrc: &std::path::Path) -> anyhow::Result<BlockRepair> {
170    let existing = std::fs::read_to_string(bashrc).unwrap_or_default();
171
172    if !has_block(&existing) {
173        install_block(bashrc)?;
174        return Ok(BlockRepair::Created);
175    }
176
177    let duplicated = count_blocks(&existing) > 1;
178    let missing_ctrl_r = !has_ctrl_r_bind(&existing);
179    let outdated = block_state(&existing) == BlockState::Legacy;
180    if !duplicated && !missing_ctrl_r && !outdated {
181        return Ok(BlockRepair::AlreadyOk);
182    }
183
184    backup_and_replace(bashrc, &existing)?;
185
186    Ok(if duplicated {
187        BlockRepair::Deduplicated
188    } else if outdated {
189        BlockRepair::Upgraded
190    } else {
191        BlockRepair::CtrlRRestored
192    })
193}
194
195/// Résultat d'une mise à niveau explicite via `mnemo shell upgrade`.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum ShellUpgrade {
198    /// Aucun bloc installé : rien à mettre à niveau (lancer `mnemo init`).
199    NotInstalled,
200    /// Bloc déjà à jour : aucune modification effectuée.
201    AlreadyCurrent,
202    /// Bloc obsolète mis à niveau. Contient le chemin de la sauvegarde créée.
203    Upgraded { backup: std::path::PathBuf },
204}
205
206/// Met à niveau un bloc mnemo « legacy » vers la version courante.
207///
208/// Contrairement à [`install_block`], ne crée jamais un bloc absent (réservé à
209/// `mnemo init`). Ne modifie le fichier que si une mise à niveau est
210/// nécessaire, toujours après sauvegarde et sans toucher au reste du `.bashrc`.
211pub fn upgrade_block(bashrc: &std::path::Path) -> anyhow::Result<ShellUpgrade> {
212    let existing = std::fs::read_to_string(bashrc).unwrap_or_default();
213    match block_state(&existing) {
214        BlockState::Absent => Ok(ShellUpgrade::NotInstalled),
215        BlockState::Current => Ok(ShellUpgrade::AlreadyCurrent),
216        BlockState::Legacy => {
217            let backup = backup_and_replace(bashrc, &existing)?;
218            Ok(ShellUpgrade::Upgraded { backup })
219        }
220    }
221}
222
223/// Sauvegarde le `.bashrc`, en retire tout bloc mnemo existant, puis écrit un
224/// bloc propre et à jour. Retourne le chemin de la sauvegarde créée.
225///
226/// Le fichier est supposé exister (un bloc mnemo y est présent). Non destructif
227/// vis-à-vis du reste du fichier.
228fn backup_and_replace(
229    bashrc: &std::path::Path,
230    existing: &str,
231) -> anyhow::Result<std::path::PathBuf> {
232    let backup = bashrc.with_file_name(format!(".bashrc.mnemo.bak.{}", compact_now()));
233    std::fs::copy(bashrc, &backup)?;
234
235    let mut content = strip_blocks(existing);
236    while content.ends_with("\n\n") {
237        content.pop();
238    }
239    if !content.is_empty() && !content.ends_with('\n') {
240        content.push('\n');
241    }
242    content.push_str(&wrapped_block());
243    std::fs::write(bashrc, content)?;
244    Ok(backup)
245}
246
247const SNIPPET: &str = r#"# >>> mnemo >>>
248# mnemo shell integration version: 2
249# Identifiant de session : regroupe les commandes d'un même shell interactif
250# (voir `mnemo session`). Conservé pour toute la durée de vie du shell.
251if [ -z "${MNEMO_SESSION_ID:-}" ]; then
252    export MNEMO_SESSION_ID="$(date +%Y%m%dT%H%M%S)-$$"
253fi
254# Enregistre automatiquement chaque commande dans mnemo.
255__mnemo_record() {
256    local __mnemo_exit=$?
257    local __mnemo_cmd
258    __mnemo_cmd=$(HISTTIMEFORMAT='' history 1 2>/dev/null | sed 's/^ *[0-9]\+ *//')
259    if [ -n "$__mnemo_cmd" ] && [ "$__mnemo_cmd" != "$__MNEMO_LAST_CMD" ]; then
260        case "$__mnemo_cmd" in
261            mnemo|mnemo\ *) ;;
262            *)
263                __MNEMO_LAST_CMD="$__mnemo_cmd"
264                mnemo add --cmd "$__mnemo_cmd" --cwd "$PWD" --exit-code "$__mnemo_exit" >/dev/null 2>&1
265                ;;
266        esac
267    fi
268    return $__mnemo_exit
269}
270case "$PROMPT_COMMAND" in
271    *__mnemo_record*) ;;
272    *) PROMPT_COMMAND="__mnemo_record${PROMPT_COMMAND:+; $PROMPT_COMMAND}" ;;
273esac
274
275# Ctrl+R : ouvre la recherche TUI et insère la commande choisie.
276__mnemo_search() {
277    local __mnemo_selected
278    __mnemo_selected=$(mnemo search 2>/dev/null)
279    if [ -n "$__mnemo_selected" ]; then
280        READLINE_LINE="$__mnemo_selected"
281        READLINE_POINT=${#READLINE_LINE}
282    fi
283}
284bind -x '"\C-r": __mnemo_search' 2>/dev/null
285# <<< mnemo <<<
286"#;
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn snippet_contient_les_elements_cles() {
294        let s = bashrc_snippet();
295        assert!(s.contains("__mnemo_record"));
296        assert!(s.contains("PROMPT_COMMAND"));
297        assert!(s.contains("mnemo add"));
298        // La commande mnemo elle-même doit être exclue.
299        assert!(s.contains("mnemo|mnemo\\ *"));
300    }
301
302    #[test]
303    fn snippet_declare_la_version_courante() {
304        let s = bashrc_snippet();
305        assert!(
306            s.contains(&format!("{VERSION_MARKER} {SNIPPET_VERSION}")),
307            "le snippet doit déclarer la version {SNIPPET_VERSION}"
308        );
309        assert_eq!(block_version(&s), Some(SNIPPET_VERSION));
310        assert_eq!(block_state(&s), BlockState::Current);
311    }
312
313    #[test]
314    fn block_state_distingue_absent_legacy_courant() {
315        // Absent : aucun bloc.
316        assert_eq!(block_state("export FOO=1\n"), BlockState::Absent);
317
318        // Legacy : bloc sans version ni MNEMO_SESSION_ID.
319        let legacy =
320            format!("{BLOCK_BEGIN}\n{SNIPPET_BEGIN}\n__mnemo_record() {{ :; }}\n{BLOCK_END}\n");
321        assert_eq!(block_state(&legacy), BlockState::Legacy);
322
323        // Courant : bloc complet généré.
324        assert_eq!(block_state(&wrapped_block()), BlockState::Current);
325
326        // Bloc sans marqueur de version mais avec MNEMO_SESSION_ID : à jour.
327        let sans_marqueur = format!(
328            "{BLOCK_BEGIN}\n{SNIPPET_BEGIN}\nexport MNEMO_SESSION_ID=x\n__mnemo_record\n{BLOCK_END}\n"
329        );
330        assert_eq!(block_state(&sans_marqueur), BlockState::Current);
331    }
332
333    #[test]
334    fn upgrade_block_met_a_niveau_un_bloc_legacy() {
335        let dir = tempfile::tempdir().unwrap();
336        let bashrc = dir.path().join(".bashrc");
337
338        // Bloc legacy précédé et suivi de contenu utilisateur.
339        let legacy = format!(
340            "{BLOCK_BEGIN}\n{SNIPPET_BEGIN}\n__mnemo_record() {{ :; }}\nbind -x '\"\\C-r\": x'\n{BLOCK_END}\n"
341        );
342        std::fs::write(&bashrc, format!("export FOO=1\n{legacy}export BAR=2\n")).unwrap();
343
344        // Legacy -> mis à niveau, sauvegarde créée.
345        match upgrade_block(&bashrc).unwrap() {
346            ShellUpgrade::Upgraded { backup } => assert!(backup.exists()),
347            other => panic!("attendu Upgraded, obtenu {other:?}"),
348        }
349        let after = std::fs::read_to_string(&bashrc).unwrap();
350        assert_eq!(block_state(&after), BlockState::Current);
351        assert_eq!(count_blocks(&after), 1);
352        assert!(after.contains("export FOO=1"));
353        assert!(after.contains("export BAR=2"));
354
355        // Idempotent : déjà à jour -> aucune action.
356        assert_eq!(
357            upgrade_block(&bashrc).unwrap(),
358            ShellUpgrade::AlreadyCurrent
359        );
360    }
361
362    #[test]
363    fn upgrade_block_refuse_si_aucun_bloc() {
364        let dir = tempfile::tempdir().unwrap();
365        let bashrc = dir.path().join(".bashrc");
366        std::fs::write(&bashrc, "export FOO=1\n").unwrap();
367        assert_eq!(upgrade_block(&bashrc).unwrap(), ShellUpgrade::NotInstalled);
368        // Fichier inchangé.
369        assert_eq!(std::fs::read_to_string(&bashrc).unwrap(), "export FOO=1\n");
370    }
371
372    #[test]
373    fn repair_block_met_a_niveau_un_bloc_legacy() {
374        let dir = tempfile::tempdir().unwrap();
375        let bashrc = dir.path().join(".bashrc");
376        let legacy = format!(
377            "{BLOCK_BEGIN}\n{SNIPPET_BEGIN}\n__mnemo_record\nbind -x '\"\\C-r\": x'\n{BLOCK_END}\n"
378        );
379        std::fs::write(&bashrc, legacy).unwrap();
380        assert_eq!(repair_block(&bashrc).unwrap(), BlockRepair::Upgraded);
381        let after = std::fs::read_to_string(&bashrc).unwrap();
382        assert_eq!(block_state(&after), BlockState::Current);
383    }
384
385    #[test]
386    fn detection_du_bloc_et_des_doublons() {
387        let empty = "export FOO=1\n";
388        assert!(!has_block(empty));
389        assert_eq!(count_blocks(empty), 0);
390
391        let one = wrapped_block();
392        assert!(has_block(&one));
393        assert_eq!(count_blocks(&one), 1);
394        assert!(has_ctrl_r_bind(&one));
395
396        let two = format!("{one}\n{one}");
397        assert_eq!(count_blocks(&two), 2);
398    }
399
400    #[test]
401    fn strip_blocks_retire_le_bloc_encadre() {
402        let avant = format!("export FOO=1\n{}export BAR=2\n", wrapped_block());
403        let apres = strip_blocks(&avant);
404        assert!(!has_block(&apres));
405        assert!(apres.contains("export FOO=1"));
406        assert!(apres.contains("export BAR=2"));
407    }
408
409    #[test]
410    fn repair_block_deduplique_et_ajoute() {
411        let dir = tempfile::tempdir().unwrap();
412        let bashrc = dir.path().join(".bashrc");
413
414        // Absent -> créé.
415        std::fs::write(&bashrc, "export FOO=1\n").unwrap();
416        assert_eq!(repair_block(&bashrc).unwrap(), BlockRepair::Created);
417        assert_eq!(count_blocks(&std::fs::read_to_string(&bashrc).unwrap()), 1);
418
419        // Déjà correct -> aucune action.
420        assert_eq!(repair_block(&bashrc).unwrap(), BlockRepair::AlreadyOk);
421
422        // Doublon -> dédupliqué.
423        let one = std::fs::read_to_string(&bashrc).unwrap();
424        std::fs::write(&bashrc, format!("{one}{}", wrapped_block())).unwrap();
425        assert_eq!(repair_block(&bashrc).unwrap(), BlockRepair::Deduplicated);
426        let after = std::fs::read_to_string(&bashrc).unwrap();
427        assert_eq!(count_blocks(&after), 1);
428        assert!(has_ctrl_r_bind(&after));
429    }
430}