1use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11use std::path::Path;
12use thiserror::Error;
13
14#[derive(Debug, Error)]
16pub enum LockError {
17 #[error("failed to read lockfile: {0}")]
18 ReadError(#[from] std::io::Error),
19
20 #[error("failed to parse lockfile: {0}")]
21 ParseError(#[from] toml::de::Error),
22
23 #[error("failed to serialize lockfile: {0}")]
24 SerializeError(#[from] toml::ser::Error),
25
26 #[error("lockfile version mismatch: expected {expected}, found {found}")]
27 VersionMismatch { expected: u32, found: u32 },
28}
29
30pub const LOCK_VERSION: u32 = 1;
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35pub struct LockedPackage {
36 pub name: String,
38 pub version: String,
40 #[serde(default = "default_source")]
42 pub source: String,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub hash: Option<String>,
46}
47
48fn default_source() -> String {
49 "hackage".to_string()
50}
51
52#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
54pub struct LockedToolchain {
55 pub ghc: Option<String>,
57 pub cabal: Option<String>,
59}
60
61#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
63pub struct LockedPlan {
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub compiler_id: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub platform: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub index_state: Option<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub snapshot: Option<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub hash: Option<String>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83pub struct WorkspacePackageInfo {
84 pub name: String,
86 pub version: String,
88 pub path: String,
90}
91
92#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
94pub struct LockedWorkspace {
95 #[serde(default)]
97 pub is_workspace: bool,
98 #[serde(default, skip_serializing_if = "Vec::is_empty")]
100 pub packages: Vec<WorkspacePackageInfo>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct Lockfile {
106 pub version: u32,
108 pub created_at: DateTime<Utc>,
110 #[serde(default)]
112 pub toolchain: LockedToolchain,
113 #[serde(default)]
115 pub plan: LockedPlan,
116 #[serde(default, skip_serializing_if = "is_default_workspace")]
118 pub workspace: LockedWorkspace,
119 #[serde(default)]
121 pub packages: Vec<LockedPackage>,
122}
123
124fn is_default_workspace(w: &LockedWorkspace) -> bool {
125 !w.is_workspace && w.packages.is_empty()
126}
127
128impl Default for Lockfile {
129 fn default() -> Self {
130 Self::new()
131 }
132}
133
134impl Lockfile {
135 pub fn new() -> Self {
137 Self {
138 version: LOCK_VERSION,
139 created_at: Utc::now(),
140 toolchain: LockedToolchain::default(),
141 plan: LockedPlan::default(),
142 workspace: LockedWorkspace::default(),
143 packages: Vec::new(),
144 }
145 }
146
147 pub fn parse(s: &str) -> Result<Self, LockError> {
149 let lock: Lockfile = toml::from_str(s)?;
150 if lock.version != LOCK_VERSION {
151 return Err(LockError::VersionMismatch {
152 expected: LOCK_VERSION,
153 found: lock.version,
154 });
155 }
156 Ok(lock)
157 }
158
159 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, LockError> {
161 let content = std::fs::read_to_string(path)?;
162 Self::parse(&content)
163 }
164
165 pub fn to_string(&self) -> Result<String, LockError> {
167 Ok(toml::to_string_pretty(self)?)
168 }
169
170 pub fn to_file(&self, path: impl AsRef<Path>) -> Result<(), LockError> {
172 let content = self.to_string()?;
173 std::fs::write(path, content)?;
174 Ok(())
175 }
176
177 pub fn fingerprint(&self) -> String {
179 let mut hasher = Sha256::new();
180
181 if let Some(ref ghc) = self.toolchain.ghc {
183 hasher.update(format!("ghc:{}", ghc));
184 }
185 if let Some(ref cabal) = self.toolchain.cabal {
186 hasher.update(format!("cabal:{}", cabal));
187 }
188
189 if let Some(ref platform) = self.plan.platform {
191 hasher.update(format!("platform:{}", platform));
192 }
193 if let Some(ref index_state) = self.plan.index_state {
194 hasher.update(format!("index:{}", index_state));
195 }
196 if let Some(ref snapshot) = self.plan.snapshot {
197 hasher.update(format!("snapshot:{}", snapshot));
198 }
199
200 if self.workspace.is_workspace {
202 hasher.update("workspace:true");
203 let mut workspace_pkgs: Vec<_> = self.workspace.packages.iter().collect();
204 workspace_pkgs.sort_by(|a, b| a.name.cmp(&b.name));
205 for pkg in workspace_pkgs {
206 hasher.update(format!("local:{}@{}:{}", pkg.name, pkg.version, pkg.path));
207 }
208 }
209
210 let mut packages: Vec<_> = self.packages.iter().collect();
212 packages.sort_by(|a, b| a.name.cmp(&b.name));
213 for pkg in packages {
214 hasher.update(format!("{}@{}", pkg.name, pkg.version));
215 }
216
217 let result = hasher.finalize();
218 format!("sha256:{}", hex::encode(result))
219 }
220
221 pub fn add_package(&mut self, pkg: LockedPackage) {
223 self.packages.retain(|p| p.name != pkg.name);
225 self.packages.push(pkg);
226 }
227
228 pub fn set_toolchain(&mut self, ghc: Option<String>, cabal: Option<String>) {
230 self.toolchain.ghc = ghc;
231 self.toolchain.cabal = cabal;
232 }
233
234 pub fn set_snapshot(&mut self, snapshot: Option<String>) {
236 self.plan.snapshot = snapshot;
237 }
238
239 pub fn set_workspace(&mut self, packages: Vec<WorkspacePackageInfo>) {
241 self.workspace.is_workspace = !packages.is_empty();
242 self.workspace.packages = packages;
243 }
244
245 pub fn is_workspace(&self) -> bool {
247 self.workspace.is_workspace
248 }
249
250 pub fn workspace_package_names(&self) -> Vec<&str> {
252 self.workspace
253 .packages
254 .iter()
255 .map(|p| p.name.as_str())
256 .collect()
257 }
258}
259
260pub fn parse_freeze_file(content: &str) -> Vec<LockedPackage> {
262 let mut packages = Vec::new();
263
264 for line in content.lines() {
265 let line = line.trim();
266
267 if line.is_empty() || line.starts_with("--") {
269 continue;
270 }
271
272 let constraint = line
275 .strip_prefix("constraints:")
276 .or(Some(line))
277 .map(|s| s.trim().trim_end_matches(','));
278
279 if let Some(constraint) = constraint
280 && let Some((name, version)) = parse_constraint(constraint)
281 {
282 packages.push(LockedPackage {
283 name,
284 version,
285 source: "hackage".to_string(),
286 hash: None,
287 });
288 }
289 }
290
291 packages
292}
293
294fn parse_constraint(s: &str) -> Option<(String, String)> {
295 let s = s.trim().trim_end_matches(',');
297 let parts: Vec<&str> = s.split(" ==").collect();
298 if parts.len() == 2 {
299 let name = parts[0].trim();
300 let version = parts[1].trim();
301 if !name.is_empty() && !version.is_empty() && !name.starts_with("any.") {
302 return Some((name.to_string(), version.to_string()));
303 }
304 }
305 None
306}
307
308mod hex {
309 pub fn encode(bytes: impl AsRef<[u8]>) -> String {
310 bytes
311 .as_ref()
312 .iter()
313 .map(|b| format!("{:02x}", b))
314 .collect()
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn test_lockfile_roundtrip() {
324 let mut lock = Lockfile::new();
325 lock.set_toolchain(Some("9.8.2".to_string()), Some("3.12.1.0".to_string()));
326 lock.add_package(LockedPackage {
327 name: "text".to_string(),
328 version: "2.1.1".to_string(),
329 source: "hackage".to_string(),
330 hash: None,
331 });
332
333 let toml = lock.to_string().unwrap();
334 let parsed = Lockfile::parse(&toml).unwrap();
335
336 assert_eq!(parsed.toolchain.ghc, Some("9.8.2".to_string()));
337 assert_eq!(parsed.packages.len(), 1);
338 assert_eq!(parsed.packages[0].name, "text");
339 }
340
341 #[test]
342 fn test_workspace_lockfile_roundtrip() {
343 let mut lock = Lockfile::new();
344 lock.set_toolchain(Some("9.8.2".to_string()), Some("3.12.1.0".to_string()));
345
346 lock.set_workspace(vec![
348 WorkspacePackageInfo {
349 name: "mylib".to_string(),
350 version: "0.1.0".to_string(),
351 path: "packages/mylib".to_string(),
352 },
353 WorkspacePackageInfo {
354 name: "myapp".to_string(),
355 version: "0.1.0".to_string(),
356 path: "packages/myapp".to_string(),
357 },
358 ]);
359
360 lock.add_package(LockedPackage {
362 name: "text".to_string(),
363 version: "2.1.1".to_string(),
364 source: "hackage".to_string(),
365 hash: None,
366 });
367
368 let toml = lock.to_string().unwrap();
369 let parsed = Lockfile::parse(&toml).unwrap();
370
371 assert!(parsed.is_workspace());
372 assert_eq!(parsed.workspace.packages.len(), 2);
373 assert_eq!(parsed.packages.len(), 1);
374
375 let names = parsed.workspace_package_names();
376 assert!(names.contains(&"mylib"));
377 assert!(names.contains(&"myapp"));
378 }
379
380 #[test]
381 fn test_workspace_fingerprint_includes_packages() {
382 let mut lock1 = Lockfile::new();
383 lock1.set_workspace(vec![WorkspacePackageInfo {
384 name: "pkg1".to_string(),
385 version: "0.1.0".to_string(),
386 path: "packages/pkg1".to_string(),
387 }]);
388
389 let mut lock2 = Lockfile::new();
390 lock2.set_workspace(vec![WorkspacePackageInfo {
391 name: "pkg2".to_string(),
392 version: "0.1.0".to_string(),
393 path: "packages/pkg2".to_string(),
394 }]);
395
396 assert_ne!(lock1.fingerprint(), lock2.fingerprint());
398 }
399
400 #[test]
401 fn test_parse_constraint() {
402 assert_eq!(
403 parse_constraint("text ==2.1.1"),
404 Some(("text".to_string(), "2.1.1".to_string()))
405 );
406 assert_eq!(
407 parse_constraint(" aeson ==2.2.0.0,"),
408 Some(("aeson".to_string(), "2.2.0.0".to_string()))
409 );
410 assert_eq!(parse_constraint("any.base ==4.19"), None);
411 }
412}