1use std::io::{self, IsTerminal, Write};
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result};
9
10use crate::{backup, config};
11
12pub const BASHRC_BEGIN_MARKER: &str = "# >>> mnemo init >>>";
14pub const BASHRC_END_MARKER: &str = "# <<< mnemo init <<<";
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Decision {
20 Proceed,
22 Cancelled,
24 RequiresConfirmation,
26}
27
28pub fn interpret_confirmation(answer: &str) -> bool {
30 let a = answer.trim().to_lowercase();
31 matches!(a.as_str(), "y" | "yes" | "o" | "oui")
32}
33
34pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct UninstallActions {
55 pub remove_bin: bool,
57 pub remove_bashrc_block: bool,
59 pub remove_config: bool,
61 pub remove_data: bool,
63}
64
65pub 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
84pub fn bashrc_has_block(content: &str) -> bool {
86 content.lines().any(|l| l.trim_end() == BASHRC_BEGIN_MARKER)
87}
88
89pub 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 if !ends_with_newline && out.ends_with('\n') {
115 out.pop();
116 }
117 out
118}
119
120pub 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
129fn bashrc_path() -> PathBuf {
131 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
132 home.join(".bashrc")
133}
134
135fn 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
159pub 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 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 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 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 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 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 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 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 assert_eq!(remove_bashrc_block(content), content);
370 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 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}