1use anyhow::{bail, Context, Result};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use crate::db::SearchFilter;
16use crate::{backup, config, confirm, db, list};
17
18const PREVIEW_SAMPLES: usize = 5;
20
21pub 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 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 let secs = n
55 .checked_mul(per_unit)
56 .with_context(|| format!("durée trop grande : {spec:?}"))?;
57 Ok(secs)
58}
59
60pub(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
69pub 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
105pub 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 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 assert!(parse_duration("99999999999999999999y").is_err());
193 assert!(parse_duration(&format!("{}y", u64::MAX)).is_err());
194 }
195}