Skip to main content

hx_lock/
lib.rs

1//! Lockfile management for hx.
2//!
3//! This crate handles:
4//! - Reading and writing hx.lock
5//! - Converting from Cabal freeze/plan
6//! - Fingerprint calculation
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11use std::path::Path;
12use thiserror::Error;
13
14/// Error type for lock operations.
15#[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
30/// Current lockfile format version.
31pub const LOCK_VERSION: u32 = 1;
32
33/// A locked package.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35pub struct LockedPackage {
36    /// Package name
37    pub name: String,
38    /// Package version
39    pub version: String,
40    /// Source (hackage, workspace, git, etc.)
41    #[serde(default = "default_source")]
42    pub source: String,
43    /// Content hash
44    #[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/// Toolchain section of the lockfile.
53#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
54pub struct LockedToolchain {
55    /// GHC version
56    pub ghc: Option<String>,
57    /// Cabal version
58    pub cabal: Option<String>,
59}
60
61/// Build plan section of the lockfile.
62#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
63pub struct LockedPlan {
64    /// Compiler ID (e.g., "ghc-9.8.2")
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub compiler_id: Option<String>,
67    /// Target platform
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub platform: Option<String>,
70    /// Hackage index state
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub index_state: Option<String>,
73    /// Stackage snapshot (e.g., "lts-22.28", "nightly-2024-01-15")
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub snapshot: Option<String>,
76    /// Overall plan hash
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub hash: Option<String>,
79}
80
81/// A workspace package (local package in the workspace).
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83pub struct WorkspacePackageInfo {
84    /// Package name
85    pub name: String,
86    /// Package version
87    pub version: String,
88    /// Path relative to workspace root
89    pub path: String,
90}
91
92/// Workspace section of the lockfile.
93#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
94pub struct LockedWorkspace {
95    /// Whether this lockfile represents a workspace
96    #[serde(default)]
97    pub is_workspace: bool,
98    /// Workspace packages (local packages)
99    #[serde(default, skip_serializing_if = "Vec::is_empty")]
100    pub packages: Vec<WorkspacePackageInfo>,
101}
102
103/// The hx.lock lockfile.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct Lockfile {
106    /// Lockfile format version
107    pub version: u32,
108    /// When the lockfile was created/updated
109    pub created_at: DateTime<Utc>,
110    /// Toolchain versions
111    #[serde(default)]
112    pub toolchain: LockedToolchain,
113    /// Build plan metadata
114    #[serde(default)]
115    pub plan: LockedPlan,
116    /// Workspace information (for multi-package projects)
117    #[serde(default, skip_serializing_if = "is_default_workspace")]
118    pub workspace: LockedWorkspace,
119    /// Locked packages (external dependencies)
120    #[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    /// Create a new empty lockfile.
136    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    /// Parse a lockfile from a TOML string.
148    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    /// Parse a lockfile from a file.
160    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    /// Serialize the lockfile to a TOML string.
166    pub fn to_string(&self) -> Result<String, LockError> {
167        Ok(toml::to_string_pretty(self)?)
168    }
169
170    /// Write the lockfile to a file.
171    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    /// Calculate a fingerprint for the lockfile.
178    pub fn fingerprint(&self) -> String {
179        let mut hasher = Sha256::new();
180
181        // Include toolchain
182        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        // Include plan metadata
190        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        // Include workspace packages (sorted for determinism)
201        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        // Include external packages (sorted for determinism)
211        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    /// Add a package to the lockfile.
222    pub fn add_package(&mut self, pkg: LockedPackage) {
223        // Remove existing package with same name
224        self.packages.retain(|p| p.name != pkg.name);
225        self.packages.push(pkg);
226    }
227
228    /// Set the toolchain versions.
229    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    /// Set the Stackage snapshot.
235    pub fn set_snapshot(&mut self, snapshot: Option<String>) {
236        self.plan.snapshot = snapshot;
237    }
238
239    /// Set workspace information.
240    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    /// Check if this is a workspace lockfile.
246    pub fn is_workspace(&self) -> bool {
247        self.workspace.is_workspace
248    }
249
250    /// Get the workspace package names.
251    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
260/// Parse a Cabal freeze file and extract constraints.
261pub 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        // Skip comments and empty lines
268        if line.is_empty() || line.starts_with("--") {
269            continue;
270        }
271
272        // Look for constraint lines like "constraints: pkg ==version"
273        // or "             pkg ==version,"
274        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    // Parse "pkg ==version" or "pkg ==version,"
296    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        // Set workspace packages
347        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        // Add external dependencies
361        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        // Different workspace packages should produce different fingerprints
397        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}