Skip to main content

mnemo/lifecycle/
uninstall.rs

1//! `mnemo uninstall` : retire le binaire et l'intégration shell ; conserve les
2//! données par défaut. `--purge` supprime aussi config, base et sauvegardes,
3//! après sauvegarde et confirmation explicite.
4
5use std::io::{self, IsTerminal, Write};
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result};
9
10use crate::{backup, config};
11
12/// Marqueur d'ouverture du bloc d'intégration dans `~/.bashrc`.
13pub const BASHRC_BEGIN_MARKER: &str = "# >>> mnemo init >>>";
14/// Marqueur de fermeture du bloc d'intégration dans `~/.bashrc`.
15pub const BASHRC_END_MARKER: &str = "# <<< mnemo init <<<";
16
17/// Issue d'une demande de confirmation pour `uninstall`.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Decision {
20    /// Poursuivre la désinstallation.
21    Proceed,
22    /// L'utilisateur a refusé (réponse vide / `n` / autre) : on annule.
23    Cancelled,
24    /// Mode non interactif sans `--yes` : confirmation requise (échec propre).
25    RequiresConfirmation,
26}
27
28/// Interprète une réponse interactive : vrai si elle vaut accord.
29pub fn interpret_confirmation(answer: &str) -> bool {
30    let a = answer.trim().to_lowercase();
31    matches!(a.as_str(), "y" | "yes" | "o" | "oui")
32}
33
34/// Décide de l'issue d'une demande de confirmation (fonction pure, testable).
35///
36/// - `assume_yes` (`--yes`) : autorise directement.
37/// - Sinon, en mode non interactif : [`Decision::RequiresConfirmation`].
38/// - Sinon : selon la réponse interactive lue (`answer`).
39pub fn decide(assume_yes: bool, interactive: bool, answer: Option<&str>) -> Decision {
40    if assume_yes {
41        return Decision::Proceed;
42    }
43    if !interactive {
44        return Decision::RequiresConfirmation;
45    }
46    match answer {
47        Some(a) if interpret_confirmation(a) => Decision::Proceed,
48        _ => Decision::Cancelled,
49    }
50}
51
52/// Actions décidées par [`plan_uninstall`] (cœur logique pur et testable).
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct UninstallActions {
55    /// Supprimer le binaire (s'il est présent).
56    pub remove_bin: bool,
57    /// Retirer le bloc mnemo du `.bashrc` (s'il est présent).
58    pub remove_bashrc_block: bool,
59    /// Supprimer le dossier de configuration (uniquement en purge).
60    pub remove_config: bool,
61    /// Supprimer le dossier de données / sauvegardes (uniquement en purge).
62    pub remove_data: bool,
63}
64
65/// Décide des actions à mener selon la présence des éléments et le mode `purge`.
66///
67/// Sans `purge`, les données (config + base + sauvegardes) sont **toujours**
68/// conservées.
69pub fn plan_uninstall(
70    bin_present: bool,
71    bashrc_has_block: bool,
72    config_present: bool,
73    data_present: bool,
74    purge: bool,
75) -> UninstallActions {
76    UninstallActions {
77        remove_bin: bin_present,
78        remove_bashrc_block: bashrc_has_block,
79        remove_config: purge && config_present,
80        remove_data: purge && data_present,
81    }
82}
83
84/// Vrai si le contenu d'un `.bashrc` contient le bloc mnemo.
85pub fn bashrc_has_block(content: &str) -> bool {
86    content.lines().any(|l| l.trim_end() == BASHRC_BEGIN_MARKER)
87}
88
89/// Retire le bloc mnemo (marqueurs inclus) d'un contenu `.bashrc`.
90///
91/// Fonction pure et **idempotente** : sans bloc, le contenu est inchangé.
92pub fn remove_bashrc_block(content: &str) -> String {
93    let mut out = String::new();
94    let mut skipping = false;
95    let ends_with_newline = content.ends_with('\n');
96    let lines: Vec<&str> = content.lines().collect();
97    for line in &lines {
98        let trimmed = line.trim_end();
99        if trimmed == BASHRC_BEGIN_MARKER {
100            skipping = true;
101            continue;
102        }
103        if trimmed == BASHRC_END_MARKER {
104            skipping = false;
105            continue;
106        }
107        if !skipping {
108            out.push_str(line);
109            out.push('\n');
110        }
111    }
112    // On préserve l'absence de newline final si le fichier d'origine n'en avait
113    // pas (et qu'il reste du contenu).
114    if !ends_with_newline && out.ends_with('\n') {
115        out.pop();
116    }
117    out
118}
119
120/// Chemin du binaire installé (`MNEMO_BIN_PATH` ou `~/.local/bin/mnemo`).
121pub fn bin_path() -> PathBuf {
122    if let Ok(p) = std::env::var("MNEMO_BIN_PATH") {
123        return PathBuf::from(p);
124    }
125    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
126    home.join(".local").join("bin").join("mnemo")
127}
128
129/// Chemin du `.bashrc` de l'utilisateur.
130fn bashrc_path() -> PathBuf {
131    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
132    home.join(".bashrc")
133}
134
135/// Sauvegarde un fichier vers `<chemin>.mnemo.bak.<horodatage>` (no-op si
136/// absent). Renvoie le chemin de la sauvegarde créée.
137fn backup_file(path: &Path) -> Result<Option<PathBuf>> {
138    if !path.exists() {
139        return Ok(None);
140    }
141    let secs = std::time::SystemTime::now()
142        .duration_since(std::time::UNIX_EPOCH)
143        .map(|d| d.as_secs())
144        .unwrap_or(0);
145    let stamp = crate::db::format_timestamp(secs);
146    let digits: String = stamp.chars().filter(|c| c.is_ascii_digit()).collect();
147    let (date, time) = digits.split_at(8.min(digits.len()));
148    let backup = path.with_file_name(format!(
149        "{}.mnemo.bak.{date}-{time}",
150        path.file_name()
151            .and_then(|n| n.to_str())
152            .unwrap_or("bashrc")
153    ));
154    std::fs::copy(path, &backup)
155        .with_context(|| format!("sauvegarde de {} échouée", path.display()))?;
156    Ok(Some(backup))
157}
158
159/// Point d'entrée de la commande.
160pub fn run(dry_run: bool, assume_yes: bool, purge: bool) -> Result<()> {
161    let bin = bin_path();
162    let bashrc = bashrc_path();
163    let config_dir = config::config_dir()?;
164    let data_dir = config::data_dir()?;
165
166    let bin_present = bin.exists();
167    let bashrc_block = std::fs::read_to_string(&bashrc)
168        .map(|c| bashrc_has_block(&c))
169        .unwrap_or(false);
170    let config_present = config_dir.exists();
171    let data_present = data_dir.exists();
172
173    let actions = plan_uninstall(
174        bin_present,
175        bashrc_block,
176        config_present,
177        data_present,
178        purge,
179    );
180
181    // Récapitulatif.
182    println!(
183        "Plan de désinstallation{} :",
184        if dry_run { " (simulation)" } else { "" }
185    );
186    println!(
187        "  binaire        : {} {}",
188        bin.display(),
189        if actions.remove_bin {
190            "→ suppression"
191        } else {
192            "(absent)"
193        }
194    );
195    println!(
196        "  bloc .bashrc   : {} {}",
197        bashrc.display(),
198        if actions.remove_bashrc_block {
199            "→ retrait"
200        } else {
201            "(absent)"
202        }
203    );
204    if purge {
205        println!(
206            "  configuration  : {} {}",
207            config_dir.display(),
208            if actions.remove_config {
209                "→ SUPPRESSION"
210            } else {
211                "(absente)"
212            }
213        );
214        println!(
215            "  données        : {} {}",
216            data_dir.display(),
217            if actions.remove_data {
218                "→ SUPPRESSION (base + sauvegardes)"
219            } else {
220                "(absentes)"
221            }
222        );
223    } else {
224        println!("  configuration  : {} (conservée)", config_dir.display());
225        println!("  données        : {} (conservées)", data_dir.display());
226    }
227
228    if dry_run {
229        println!("\nSimulation : aucune modification effectuée.");
230        return Ok(());
231    }
232
233    // Confirmation OBLIGATOIRE pour toute désinstallation : la suppression du
234    // binaire et de l'intégration shell reste une action destructive, même si
235    // les données sont conservées.
236    let prompt = if purge {
237        "Cette action supprimera config, base et sauvegardes. Continuer ? [y/N] "
238    } else {
239        "Désinstaller mnemo tout en conservant les données ? [y/N] "
240    };
241
242    let interactive = io::stdin().is_terminal();
243    let answer = if !assume_yes && interactive {
244        print!("{prompt}");
245        io::stdout().flush()?;
246        let mut buf = String::new();
247        io::stdin().read_line(&mut buf)?;
248        Some(buf)
249    } else {
250        None
251    };
252
253    match decide(assume_yes, interactive, answer.as_deref()) {
254        Decision::Proceed => {}
255        Decision::Cancelled => {
256            println!("Désinstallation annulée. Aucune modification effectuée.");
257            return Ok(());
258        }
259        Decision::RequiresConfirmation => {
260            anyhow::bail!(
261                "Confirmation requise. Relancez avec --yes pour confirmer ou --dry-run pour prévisualiser."
262            );
263        }
264    }
265
266    // Purge : sauvegarde de sécurité AVANT toute suppression, placée HORS du
267    // dossier de données pour survivre à la purge.
268    if purge && (actions.remove_config || actions.remove_data) && data_present {
269        let dest = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
270        match backup::create_backup(Some(&dest)) {
271            Ok(info) => println!("Sauvegarde de sécurité créée : {}", info.path.display()),
272            Err(e) => eprintln!("Avertissement : sauvegarde impossible ({e})"),
273        }
274    }
275
276    // 1. Binaire.
277    if actions.remove_bin {
278        std::fs::remove_file(&bin)
279            .with_context(|| format!("suppression du binaire {} échouée", bin.display()))?;
280        println!("Binaire supprimé : {}", bin.display());
281    }
282
283    // 2. Bloc .bashrc (avec sauvegarde).
284    if actions.remove_bashrc_block {
285        let content = std::fs::read_to_string(&bashrc).unwrap_or_default();
286        backup_file(&bashrc)?;
287        let cleaned = remove_bashrc_block(&content);
288        std::fs::write(&bashrc, cleaned)
289            .with_context(|| format!("écriture de {} échouée", bashrc.display()))?;
290        println!(
291            "Bloc mnemo retiré de {} (sauvegarde créée)",
292            bashrc.display()
293        );
294    }
295
296    // 3. Données (purge uniquement).
297    if actions.remove_config && config_dir.exists() {
298        std::fs::remove_dir_all(&config_dir)
299            .with_context(|| format!("suppression de {} échouée", config_dir.display()))?;
300        println!("Configuration supprimée : {}", config_dir.display());
301    }
302    if actions.remove_data && data_dir.exists() {
303        std::fs::remove_dir_all(&data_dir)
304            .with_context(|| format!("suppression de {} échouée", data_dir.display()))?;
305        println!("Données supprimées : {}", data_dir.display());
306    }
307
308    if !purge {
309        println!("\nDonnées conservées (config, base et sauvegardes intactes).");
310        println!("Pour tout supprimer : mnemo uninstall --purge");
311    } else {
312        println!("\nDésinstallation complète terminée.");
313    }
314    println!("Pensez à recharger votre shell : source ~/.bashrc");
315    Ok(())
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn plan_sans_purge_conserve_donnees() {
324        let a = plan_uninstall(true, true, true, true, false);
325        assert!(a.remove_bin);
326        assert!(a.remove_bashrc_block);
327        assert!(!a.remove_config);
328        assert!(!a.remove_data);
329    }
330
331    #[test]
332    fn plan_purge_supprime_donnees() {
333        let a = plan_uninstall(true, true, true, true, true);
334        assert!(a.remove_bin);
335        assert!(a.remove_bashrc_block);
336        assert!(a.remove_config);
337        assert!(a.remove_data);
338    }
339
340    #[test]
341    fn plan_purge_respecte_absence() {
342        // Purge demandée mais rien n'est présent : aucune action.
343        let a = plan_uninstall(false, false, false, false, true);
344        assert!(!a.remove_bin);
345        assert!(!a.remove_bashrc_block);
346        assert!(!a.remove_config);
347        assert!(!a.remove_data);
348    }
349
350    #[test]
351    fn retrait_bloc_bashrc() {
352        let content = "\
353export PATH=$HOME/bin:$PATH
354# >>> mnemo init >>>
355source /tmp/mnemo.sh
356# <<< mnemo init <<<
357alias ll='ls -la'
358";
359        let cleaned = remove_bashrc_block(content);
360        assert!(!cleaned.contains("mnemo"));
361        assert!(cleaned.contains("export PATH"));
362        assert!(cleaned.contains("alias ll"));
363    }
364
365    #[test]
366    fn retrait_bloc_idempotent() {
367        let content = "alias g='git'\nexport EDITOR=vim\n";
368        // Aucun bloc : contenu inchangé.
369        assert_eq!(remove_bashrc_block(content), content);
370        // Double application : stable.
371        let once = remove_bashrc_block(content);
372        assert_eq!(remove_bashrc_block(&once), once);
373    }
374
375    #[test]
376    fn detection_bloc() {
377        assert!(bashrc_has_block("a\n# >>> mnemo init >>>\nb\n"));
378        assert!(!bashrc_has_block("a\nb\n"));
379    }
380
381    #[test]
382    fn interpretation_reponse() {
383        for ok in ["y", "Y", "yes", "Yes", "o", "O", "oui", " y \n"] {
384            assert!(interpret_confirmation(ok), "{ok:?} doit valoir accord");
385        }
386        for ko in ["", "n", "no", "non", "x", " "] {
387            assert!(!interpret_confirmation(ko), "{ko:?} doit valoir refus");
388        }
389    }
390
391    #[test]
392    fn decision_yes_force_proceed() {
393        // --yes l'emporte, interactif ou non.
394        assert_eq!(decide(true, false, None), Decision::Proceed);
395        assert_eq!(decide(true, true, Some("n")), Decision::Proceed);
396    }
397
398    #[test]
399    fn decision_non_interactif_requiert_confirmation() {
400        assert_eq!(decide(false, false, None), Decision::RequiresConfirmation);
401    }
402
403    #[test]
404    fn decision_interactive_oui_execute() {
405        assert_eq!(decide(false, true, Some("y")), Decision::Proceed);
406        assert_eq!(decide(false, true, Some("yes")), Decision::Proceed);
407    }
408
409    #[test]
410    fn decision_interactive_non_annule() {
411        assert_eq!(decide(false, true, Some("n")), Decision::Cancelled);
412        assert_eq!(decide(false, true, Some("")), Decision::Cancelled);
413        assert_eq!(decide(false, true, None), Decision::Cancelled);
414    }
415}