Skip to main content

stout_state/
installed.rs

1//! Installed packages tracking
2
3use crate::error::Result;
4use crate::paths::Paths;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Information about an installed package
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct InstalledPackage {
11    pub version: String,
12    #[serde(default)]
13    pub revision: u32,
14    pub installed_at: String,
15    #[serde(default = "default_installed_by")]
16    pub installed_by: String,
17    #[serde(default)]
18    pub requested: bool,
19    #[serde(default)]
20    pub pinned: bool,
21    #[serde(default)]
22    pub dependencies: Vec<String>,
23    /// Full commit SHA for HEAD installations
24    #[serde(default)]
25    pub head_sha: Option<String>,
26    /// Quick flag for HEAD detection
27    #[serde(default)]
28    pub is_head: bool,
29    /// SHA256 of the installed bottle (for reinstall optimization)
30    #[serde(default)]
31    pub bottle_sha256: Option<String>,
32}
33
34fn default_installed_by() -> String {
35    "stout".to_string()
36}
37
38impl InstalledPackage {
39    /// Check if this is a HEAD installation
40    pub fn is_head_install(&self) -> bool {
41        self.is_head || self.version.starts_with("HEAD")
42    }
43
44    /// Get the short SHA for display (from version string)
45    pub fn short_sha(&self) -> Option<&str> {
46        if self.version.starts_with("HEAD-") {
47            Some(&self.version[5..])
48        } else {
49            None
50        }
51    }
52}
53
54/// Collection of installed packages
55#[derive(Debug, Default, Clone, Serialize, Deserialize)]
56pub struct InstalledPackages {
57    #[serde(default)]
58    pub packages: HashMap<String, InstalledPackage>,
59}
60
61impl InstalledPackages {
62    /// Load installed packages from file
63    pub fn load(paths: &Paths) -> Result<Self> {
64        let file_path = paths.installed_file();
65
66        if file_path.exists() {
67            let contents = std::fs::read_to_string(&file_path)?;
68            let packages: InstalledPackages = toml::from_str(&contents)?;
69            Ok(packages)
70        } else {
71            Ok(Self::default())
72        }
73    }
74
75    /// Save installed packages to file
76    pub fn save(&self, paths: &Paths) -> Result<()> {
77        let file_path = paths.installed_file();
78
79        if let Some(parent) = file_path.parent() {
80            std::fs::create_dir_all(parent)?;
81        }
82
83        let contents = toml::to_string_pretty(self)?;
84        std::fs::write(&file_path, contents)?;
85        Ok(())
86    }
87
88    /// Add or update a package
89    pub fn add(&mut self, name: &str, version: &str, revision: u32, requested: bool) {
90        self.add_with_deps(name, version, revision, requested, Vec::new());
91    }
92
93    /// Add or update a package with dependencies
94    pub fn add_with_deps(
95        &mut self,
96        name: &str,
97        version: &str,
98        revision: u32,
99        requested: bool,
100        dependencies: Vec<String>,
101    ) {
102        let now = chrono_lite_now();
103        // Preserve pinned status if updating existing package
104        let pinned = self.packages.get(name).map(|p| p.pinned).unwrap_or(false);
105        self.packages.insert(
106            name.to_string(),
107            InstalledPackage {
108                version: version.to_string(),
109                revision,
110                installed_at: now,
111                installed_by: "stout".to_string(),
112                requested,
113                pinned,
114                dependencies,
115                head_sha: None,
116                is_head: version.starts_with("HEAD"),
117                bottle_sha256: None,
118            },
119        );
120    }
121
122    /// Add or update a package with full metadata (used by import/sync)
123    #[allow(clippy::too_many_arguments)]
124    pub fn add_imported(
125        &mut self,
126        name: &str,
127        version: &str,
128        revision: u32,
129        requested: bool,
130        installed_by: &str,
131        installed_at: &str,
132        dependencies: Vec<String>,
133    ) {
134        // Preserve pinned status if updating existing package
135        let pinned = self.packages.get(name).map(|p| p.pinned).unwrap_or(false);
136        self.packages.insert(
137            name.to_string(),
138            InstalledPackage {
139                version: version.to_string(),
140                revision,
141                installed_at: installed_at.to_string(),
142                installed_by: installed_by.to_string(),
143                requested,
144                pinned,
145                dependencies,
146                head_sha: None,
147                is_head: version.starts_with("HEAD"),
148                bottle_sha256: None,
149            },
150        );
151    }
152
153    /// Add a HEAD package with SHA tracking
154    pub fn add_head(
155        &mut self,
156        name: &str,
157        short_sha: &str,
158        full_sha: &str,
159        requested: bool,
160        dependencies: Vec<String>,
161    ) {
162        let now = chrono_lite_now();
163        // Preserve pinned status if updating existing package
164        let pinned = self.packages.get(name).map(|p| p.pinned).unwrap_or(false);
165        self.packages.insert(
166            name.to_string(),
167            InstalledPackage {
168                version: format!("HEAD-{}", short_sha),
169                revision: 0,
170                installed_at: now,
171                installed_by: "stout".to_string(),
172                requested,
173                pinned,
174                dependencies,
175                head_sha: Some(full_sha.to_string()),
176                is_head: true,
177                bottle_sha256: None,
178            },
179        );
180    }
181
182    /// Pin a package to prevent upgrades
183    pub fn pin(&mut self, name: &str) -> bool {
184        if let Some(pkg) = self.packages.get_mut(name) {
185            pkg.pinned = true;
186            true
187        } else {
188            false
189        }
190    }
191
192    /// Unpin a package to allow upgrades
193    pub fn unpin(&mut self, name: &str) -> bool {
194        if let Some(pkg) = self.packages.get_mut(name) {
195            pkg.pinned = false;
196            true
197        } else {
198            false
199        }
200    }
201
202    /// Check if a package is pinned
203    pub fn is_pinned(&self, name: &str) -> bool {
204        self.packages.get(name).map(|p| p.pinned).unwrap_or(false)
205    }
206
207    /// List pinned packages
208    pub fn pinned(&self) -> impl Iterator<Item = (&String, &InstalledPackage)> {
209        self.packages.iter().filter(|(_, p)| p.pinned)
210    }
211
212    /// Remove a package
213    pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> {
214        self.packages.remove(name)
215    }
216
217    /// Get a package
218    pub fn get(&self, name: &str) -> Option<&InstalledPackage> {
219        self.packages.get(name)
220    }
221
222    /// Check if a package is installed
223    pub fn is_installed(&self, name: &str) -> bool {
224        self.packages.contains_key(name)
225    }
226
227    /// Check if a specific version is installed
228    pub fn is_version_installed(&self, name: &str, version: &str) -> bool {
229        self.packages
230            .get(name)
231            .map(|p| p.version == version)
232            .unwrap_or(false)
233    }
234
235    /// Get all installed package names
236    pub fn names(&self) -> impl Iterator<Item = &String> {
237        self.packages.keys()
238    }
239
240    /// Get count of installed packages
241    pub fn count(&self) -> usize {
242        self.packages.len()
243    }
244
245    /// List packages that were explicitly requested (not dependencies)
246    pub fn requested(&self) -> impl Iterator<Item = (&String, &InstalledPackage)> {
247        self.packages.iter().filter(|(_, p)| p.requested)
248    }
249
250    /// List packages that are dependencies
251    pub fn dependencies(&self) -> impl Iterator<Item = (&String, &InstalledPackage)> {
252        self.packages.iter().filter(|(_, p)| !p.requested)
253    }
254
255    /// Iterate over all installed packages
256    pub fn iter(&self) -> impl Iterator<Item = (&String, &InstalledPackage)> {
257        self.packages.iter()
258    }
259}
260
261/// Current time as ISO 8601 UTC string.
262fn chrono_lite_now() -> String {
263    jiff::Timestamp::now()
264        .strftime("%Y-%m-%dT%H:%M:%SZ")
265        .to_string()
266}