otter_pm/
lockfile.rs

1//! Lockfile format (otter.lock)
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::Path;
7
8/// Lockfile structure (otter.lock)
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub struct Lockfile {
11    /// Lockfile format version
12    pub version: u32,
13
14    /// Locked packages
15    pub packages: HashMap<String, LockfileEntry>,
16}
17
18impl Lockfile {
19    pub fn new() -> Self {
20        Self {
21            version: 1,
22            packages: HashMap::new(),
23        }
24    }
25
26    /// Load lockfile from path
27    pub fn load(path: &Path) -> Result<Self, LockfileError> {
28        let content = fs::read_to_string(path).map_err(|e| LockfileError::Io(e.to_string()))?;
29
30        serde_json::from_str(&content).map_err(|e| LockfileError::Parse(e.to_string()))
31    }
32
33    /// Save lockfile to path
34    pub fn save(&self, path: &Path) -> Result<(), LockfileError> {
35        let content =
36            serde_json::to_string_pretty(self).map_err(|e| LockfileError::Parse(e.to_string()))?;
37
38        fs::write(path, content).map_err(|e| LockfileError::Io(e.to_string()))
39    }
40
41    /// Check if a package is locked at a specific version
42    pub fn is_locked(&self, name: &str, version: &str) -> bool {
43        self.packages
44            .get(name)
45            .is_some_and(|entry| entry.version == version)
46    }
47
48    /// Get locked version for a package
49    pub fn get_version(&self, name: &str) -> Option<&str> {
50        self.packages.get(name).map(|e| e.version.as_str())
51    }
52}
53
54/// Single package entry in lockfile
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct LockfileEntry {
57    /// Exact version installed
58    pub version: String,
59
60    /// Resolved tarball URL
61    pub resolved: String,
62
63    /// Integrity hash (SHA-512)
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub integrity: Option<String>,
66
67    /// Dependencies with version requirements
68    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
69    pub dependencies: HashMap<String, String>,
70}
71
72#[derive(Debug, thiserror::Error)]
73pub enum LockfileError {
74    #[error("IO error: {0}")]
75    Io(String),
76
77    #[error("Parse error: {0}")]
78    Parse(String),
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn test_lockfile_new() {
87        let lockfile = Lockfile::new();
88        assert_eq!(lockfile.version, 1);
89        assert!(lockfile.packages.is_empty());
90    }
91
92    #[test]
93    fn test_lockfile_serialize() {
94        let mut lockfile = Lockfile::new();
95        lockfile.packages.insert(
96            "lodash".to_string(),
97            LockfileEntry {
98                version: "4.17.21".to_string(),
99                resolved: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz".to_string(),
100                integrity: Some("sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==".to_string()),
101                dependencies: HashMap::new(),
102            },
103        );
104
105        let json = serde_json::to_string_pretty(&lockfile).unwrap();
106        assert!(json.contains("lodash"));
107        assert!(json.contains("4.17.21"));
108    }
109
110    #[test]
111    fn test_lockfile_deserialize() {
112        let json = r#"{
113            "version": 1,
114            "packages": {
115                "lodash": {
116                    "version": "4.17.21",
117                    "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
118                }
119            }
120        }"#;
121
122        let lockfile: Lockfile = serde_json::from_str(json).unwrap();
123        assert_eq!(lockfile.version, 1);
124        assert!(lockfile.packages.contains_key("lodash"));
125        assert_eq!(lockfile.packages["lodash"].version, "4.17.21");
126    }
127
128    #[test]
129    fn test_is_locked() {
130        let mut lockfile = Lockfile::new();
131        lockfile.packages.insert(
132            "lodash".to_string(),
133            LockfileEntry {
134                version: "4.17.21".to_string(),
135                resolved: "https://example.com".to_string(),
136                integrity: None,
137                dependencies: HashMap::new(),
138            },
139        );
140
141        assert!(lockfile.is_locked("lodash", "4.17.21"));
142        assert!(!lockfile.is_locked("lodash", "4.17.20"));
143        assert!(!lockfile.is_locked("underscore", "1.0.0"));
144    }
145}