Skip to main content

stout_state/
history.rs

1//! Package installation history tracking
2
3use crate::error::Result;
4use crate::paths::Paths;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Action that was performed on a package
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum HistoryAction {
12    Install,
13    Upgrade,
14    Downgrade,
15    Reinstall,
16    Uninstall,
17}
18
19impl HistoryAction {
20    pub fn as_str(&self) -> &'static str {
21        match self {
22            Self::Install => "install",
23            Self::Upgrade => "upgrade",
24            Self::Downgrade => "downgrade",
25            Self::Reinstall => "reinstall",
26            Self::Uninstall => "uninstall",
27        }
28    }
29}
30
31/// A single history entry for a package
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct HistoryEntry {
34    pub version: String,
35    pub revision: u32,
36    pub action: HistoryAction,
37    pub timestamp: String,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub from_version: Option<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub from_revision: Option<u32>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub bottle_path: Option<String>,
44}
45
46/// Package history storage
47#[derive(Debug, Default, Clone, Serialize, Deserialize)]
48pub struct PackageHistory {
49    #[serde(default)]
50    pub packages: HashMap<String, Vec<HistoryEntry>>,
51}
52
53impl PackageHistory {
54    /// Load history from file
55    pub fn load(paths: &Paths) -> Result<Self> {
56        let file_path = paths.history_file();
57
58        if file_path.exists() {
59            let contents = std::fs::read_to_string(&file_path)?;
60            let history: PackageHistory = serde_json::from_str(&contents)?;
61            Ok(history)
62        } else {
63            Ok(Self::default())
64        }
65    }
66
67    /// Save history to file
68    pub fn save(&self, paths: &Paths) -> Result<()> {
69        let file_path = paths.history_file();
70
71        if let Some(parent) = file_path.parent() {
72            std::fs::create_dir_all(parent)?;
73        }
74
75        let contents = serde_json::to_string_pretty(self)?;
76        std::fs::write(&file_path, contents)?;
77        Ok(())
78    }
79
80    /// Record an install action
81    pub fn record_install(&mut self, name: &str, version: &str, revision: u32) {
82        self.record(name, version, revision, HistoryAction::Install, None, None);
83    }
84
85    /// Record an upgrade action
86    pub fn record_upgrade(
87        &mut self,
88        name: &str,
89        version: &str,
90        revision: u32,
91        from_version: &str,
92        from_revision: u32,
93    ) {
94        self.record(
95            name,
96            version,
97            revision,
98            HistoryAction::Upgrade,
99            Some(from_version.to_string()),
100            Some(from_revision),
101        );
102    }
103
104    /// Record a downgrade action
105    pub fn record_downgrade(
106        &mut self,
107        name: &str,
108        version: &str,
109        revision: u32,
110        from_version: &str,
111        from_revision: u32,
112    ) {
113        self.record(
114            name,
115            version,
116            revision,
117            HistoryAction::Downgrade,
118            Some(from_version.to_string()),
119            Some(from_revision),
120        );
121    }
122
123    /// Record a reinstall action
124    pub fn record_reinstall(&mut self, name: &str, version: &str, revision: u32) {
125        self.record(
126            name,
127            version,
128            revision,
129            HistoryAction::Reinstall,
130            None,
131            None,
132        );
133    }
134
135    /// Record an uninstall action
136    pub fn record_uninstall(&mut self, name: &str, version: &str, revision: u32) {
137        self.record(
138            name,
139            version,
140            revision,
141            HistoryAction::Uninstall,
142            None,
143            None,
144        );
145    }
146
147    /// Record a history entry
148    fn record(
149        &mut self,
150        name: &str,
151        version: &str,
152        revision: u32,
153        action: HistoryAction,
154        from_version: Option<String>,
155        from_revision: Option<u32>,
156    ) {
157        let entry = HistoryEntry {
158            version: version.to_string(),
159            revision,
160            action,
161            timestamp: chrono_lite_now(),
162            from_version,
163            from_revision,
164            bottle_path: None,
165        };
166
167        self.packages
168            .entry(name.to_string())
169            .or_default()
170            .push(entry);
171    }
172
173    /// Get history for a specific package
174    pub fn get(&self, name: &str) -> Option<&Vec<HistoryEntry>> {
175        self.packages.get(name)
176    }
177
178    /// Get the most recent entry for a package
179    pub fn get_latest(&self, name: &str) -> Option<&HistoryEntry> {
180        self.packages.get(name).and_then(|entries| entries.last())
181    }
182
183    /// Get the previous version for a package (before current)
184    pub fn get_previous(&self, name: &str) -> Option<&HistoryEntry> {
185        self.packages.get(name).and_then(|entries| {
186            if entries.len() >= 2 {
187                // Find the most recent non-uninstall entry before the last one
188                entries
189                    .iter()
190                    .rev()
191                    .skip(1)
192                    .find(|e| e.action != HistoryAction::Uninstall)
193            } else {
194                None
195            }
196        })
197    }
198
199    /// Get all versions that were installed for a package
200    pub fn get_installed_versions(&self, name: &str) -> Vec<(String, u32)> {
201        self.packages
202            .get(name)
203            .map(|entries| {
204                let mut versions: Vec<(String, u32)> = entries
205                    .iter()
206                    .filter(|e| e.action != HistoryAction::Uninstall)
207                    .map(|e| (e.version.clone(), e.revision))
208                    .collect();
209                versions.dedup();
210                versions
211            })
212            .unwrap_or_default()
213    }
214
215    /// Check if a package has any history
216    pub fn has_history(&self, name: &str) -> bool {
217        self.packages
218            .get(name)
219            .map(|e| !e.is_empty())
220            .unwrap_or(false)
221    }
222
223    /// Prune history to keep only the last N entries per package
224    pub fn prune(&mut self, keep: usize) {
225        for entries in self.packages.values_mut() {
226            if entries.len() > keep {
227                let start = entries.len() - keep;
228                *entries = entries.drain(start..).collect();
229            }
230        }
231    }
232
233    /// Remove all history for a package
234    pub fn remove(&mut self, name: &str) {
235        self.packages.remove(name);
236    }
237}
238
239/// Simple timestamp without pulling in chrono
240fn chrono_lite_now() -> String {
241    use std::time::{SystemTime, UNIX_EPOCH};
242
243    let duration = SystemTime::now()
244        .duration_since(UNIX_EPOCH)
245        .unwrap_or_default();
246
247    let secs = duration.as_secs();
248
249    // Simple ISO 8601 format
250    let days_since_epoch = secs / 86400;
251    let remaining_secs = secs % 86400;
252    let hours = remaining_secs / 3600;
253    let minutes = (remaining_secs % 3600) / 60;
254    let seconds = remaining_secs % 60;
255
256    // Approximate year calculation (doesn't account for leap years perfectly)
257    let years = 1970 + (days_since_epoch / 365);
258    let day_of_year = days_since_epoch % 365;
259    let month = (day_of_year / 30).min(11) + 1;
260    let day = (day_of_year % 30) + 1;
261
262    format!(
263        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
264        years, month, day, hours, minutes, seconds
265    )
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn test_record_history() {
274        let mut history = PackageHistory::default();
275
276        history.record_install("jq", "1.7", 0);
277        history.record_upgrade("jq", "1.7.1", 0, "1.7", 0);
278
279        let entries = history.get("jq").unwrap();
280        assert_eq!(entries.len(), 2);
281        assert_eq!(entries[0].action, HistoryAction::Install);
282        assert_eq!(entries[1].action, HistoryAction::Upgrade);
283        assert_eq!(entries[1].from_version, Some("1.7".to_string()));
284    }
285
286    #[test]
287    fn test_get_previous() {
288        let mut history = PackageHistory::default();
289
290        history.record_install("jq", "1.6", 0);
291        history.record_upgrade("jq", "1.7", 0, "1.6", 0);
292        history.record_upgrade("jq", "1.7.1", 0, "1.7", 0);
293
294        let prev = history.get_previous("jq").unwrap();
295        assert_eq!(prev.version, "1.7");
296    }
297
298    #[test]
299    fn test_prune() {
300        let mut history = PackageHistory::default();
301
302        history.record_install("jq", "1.5", 0);
303        history.record_upgrade("jq", "1.6", 0, "1.5", 0);
304        history.record_upgrade("jq", "1.7", 0, "1.6", 0);
305        history.record_upgrade("jq", "1.7.1", 0, "1.7", 0);
306
307        history.prune(2);
308
309        let entries = history.get("jq").unwrap();
310        assert_eq!(entries.len(), 2);
311        assert_eq!(entries[0].version, "1.7");
312        assert_eq!(entries[1].version, "1.7.1");
313    }
314}