Skip to main content

mnemo/
prune.rs

1//! Suppression sûre de commandes (`mnemo delete`) et nettoyage par ancienneté
2//! (`mnemo prune`).
3//!
4//! Sécurité (v0.3) :
5//! - on affiche toujours ce qui sera touché avant d'agir ;
6//! - `--dry-run` ne supprime rien ;
7//! - sans `--yes`, une confirmation interactive est demandée (refus en mode
8//!   non interactif) ;
9//! - une sauvegarde automatique est créée avant toute suppression réelle ;
10//! - les suppressions s'exécutent dans une transaction SQLite.
11
12use anyhow::{bail, Context, Result};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use crate::db::SearchFilter;
16use crate::{backup, config, confirm, db, list};
17
18/// Nombre d'exemples affichés en aperçu d'un `prune`.
19const PREVIEW_SAMPLES: usize = 5;
20
21/// Convertit une durée lisible (`24h`, `30d`, `12w`, `6m`, `1y`) en secondes.
22///
23/// Approximations documentées : `w` = 7 jours, `m` = 30 jours, `y` = 365 jours.
24pub fn parse_duration(spec: &str) -> Result<u64> {
25    const HOUR: u64 = 3_600;
26    const DAY: u64 = 86_400;
27    let spec = spec.trim();
28
29    // L'unité est le dernier caractère ; on découpe sur une frontière de
30    // caractère UTF-8 pour ne jamais paniquer sur une entrée multi-octets.
31    let unit = spec
32        .chars()
33        .next_back()
34        .with_context(|| format!("durée invalide : {spec:?} (exemples : 24h, 30d, 12w, 6m, 1y)"))?;
35    let num = &spec[..spec.len() - unit.len_utf8()];
36    if num.is_empty() {
37        bail!("durée invalide : {spec:?} (exemples : 24h, 30d, 12w, 6m, 1y)");
38    }
39    let n: u64 = num
40        .parse()
41        .with_context(|| format!("durée invalide : {spec:?} (exemples : 24h, 30d, 12w, 6m, 1y)"))?;
42    if n == 0 {
43        bail!("durée invalide : {spec:?} (doit être strictement positive)");
44    }
45    let per_unit: u64 = match unit {
46        'h' => HOUR,
47        'd' => DAY,
48        'w' => 7 * DAY,
49        'm' => 30 * DAY,
50        'y' => 365 * DAY,
51        other => bail!("unité de durée inconnue : {other:?} (utilisez h, d, w, m ou y)"),
52    };
53    // `checked_mul` évite tout débordement sur des valeurs déraisonnables.
54    let secs = n
55        .checked_mul(per_unit)
56        .with_context(|| format!("durée trop grande : {spec:?}"))?;
57    Ok(secs)
58}
59
60/// Horodatage `YYYY-MM-DD HH:MM:SS` correspondant à « il y a `secs` secondes ».
61pub(crate) fn cutoff_timestamp(secs: u64) -> String {
62    let now = SystemTime::now()
63        .duration_since(UNIX_EPOCH)
64        .map(|d| d.as_secs())
65        .unwrap_or(0);
66    db::format_timestamp(now.saturating_sub(secs))
67}
68
69/// Point d'entrée de `mnemo delete <id>`.
70pub fn delete_run(id: i64, dry_run: bool, assume_yes: bool) -> Result<()> {
71    let conn = db::open(&config::db_path()?)?;
72
73    let record = match db::get_command(&conn, id)? {
74        Some(r) => r,
75        None => {
76            println!("Aucune commande avec l'ID {id}.");
77            return Ok(());
78        }
79    };
80
81    println!("Commande ciblée :");
82    println!("{}", list::short_line(&record));
83
84    if dry_run {
85        println!("\n[dry-run] Aucune suppression effectuée.");
86        return Ok(());
87    }
88
89    if !confirm::confirm(
90        &format!("Supprimer définitivement la commande {id} ?"),
91        assume_yes,
92    )? {
93        println!("Suppression annulée.");
94        return Ok(());
95    }
96
97    let safety = backup::create_backup(None)?;
98    println!("Sauvegarde automatique : {}", safety.path.display());
99
100    let n = db::delete_command(&conn, id)?;
101    println!("{n} commande supprimée.");
102    Ok(())
103}
104
105/// Point d'entrée de `mnemo prune --older-than <durée>`.
106pub fn prune_run(
107    older_than: String,
108    project: Option<String>,
109    branch: Option<String>,
110    dry_run: bool,
111    assume_yes: bool,
112) -> Result<()> {
113    let secs = parse_duration(&older_than)?;
114    let cutoff = cutoff_timestamp(secs);
115
116    let conn = db::open(&config::db_path()?)?;
117    let filter = SearchFilter { project, branch };
118
119    let total = db::count_older_than(&conn, &cutoff, &filter)?;
120    if total == 0 {
121        println!("Aucune commande antérieure à {cutoff} (--older-than {older_than}).");
122        return Ok(());
123    }
124
125    println!(
126        "{total} commande(s) antérieure(s) à {cutoff} (--older-than {older_than}) seront supprimées."
127    );
128    let samples = db::fetch_older_than(&conn, &cutoff, &filter, PREVIEW_SAMPLES)?;
129    if !samples.is_empty() {
130        println!("Exemples :");
131        for r in &samples {
132            println!("{}", list::short_line(r));
133        }
134    }
135
136    if dry_run {
137        println!("\n[dry-run] Aucune suppression effectuée.");
138        return Ok(());
139    }
140
141    if !confirm::confirm(&format!("Supprimer ces {total} commande(s) ?"), assume_yes)? {
142        println!("Nettoyage annulé.");
143        return Ok(());
144    }
145
146    let safety = backup::create_backup(None)?;
147    println!("Sauvegarde automatique : {}", safety.path.display());
148
149    let n = db::delete_older_than(&conn, &cutoff, &filter)?;
150    println!("{n} commande(s) supprimée(s).");
151    Ok(())
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn parse_duration_valide() {
160        const DAY: u64 = 86_400;
161        assert_eq!(parse_duration("24h").unwrap(), 24 * 3_600);
162        assert_eq!(parse_duration("30d").unwrap(), 30 * DAY);
163        assert_eq!(parse_duration("12w").unwrap(), 12 * 7 * DAY);
164        assert_eq!(parse_duration("6m").unwrap(), 6 * 30 * DAY);
165        assert_eq!(parse_duration("1y").unwrap(), 365 * DAY);
166    }
167
168    #[test]
169    fn parse_duration_erreurs() {
170        assert!(parse_duration("").is_err());
171        assert!(parse_duration("d").is_err());
172        assert!(parse_duration("30").is_err());
173        assert!(parse_duration("30x").is_err());
174        assert!(parse_duration("0d").is_err());
175        assert!(parse_duration("abc").is_err());
176    }
177
178    #[test]
179    fn parse_duration_multibyte_ne_panique_pas() {
180        // Régression (fuzzing) : une entrée multi-octets ne doit jamais paniquer
181        // en découpant l'unité au milieu d'un caractère UTF-8.
182        assert!(parse_duration("Ώ").is_err());
183        assert!(parse_duration("1Ώ").is_err());
184        assert!(parse_duration("é").is_err());
185        assert!(parse_duration("24é").is_err());
186    }
187
188    #[test]
189    fn parse_duration_overflow_est_une_erreur() {
190        // Régression (fuzzing) : une durée démesurée renvoie une erreur propre,
191        // sans débordement arithmétique.
192        assert!(parse_duration("99999999999999999999y").is_err());
193        assert!(parse_duration(&format!("{}y", u64::MAX)).is_err());
194    }
195}