Skip to main content

tl_package/
lockfile.rs

1use serde::{Deserialize, Serialize};
2use std::path::Path;
3
4/// Lock file for pinning resolved dependency versions.
5#[derive(Debug, Clone, Serialize, Deserialize, Default)]
6pub struct LockFile {
7    #[serde(default)]
8    pub packages: Vec<LockedPackage>,
9}
10
11/// A single locked package entry.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13pub struct LockedPackage {
14    pub name: String,
15    pub version: String,
16    /// Source descriptor: "git+url#rev", "path+/absolute/path"
17    pub source: String,
18    /// Whether this is a direct dependency (vs transitive).
19    #[serde(default = "default_true")]
20    pub direct: bool,
21    /// Names of packages this package depends on.
22    #[serde(default, skip_serializing_if = "Vec::is_empty")]
23    pub dependencies: Vec<String>,
24}
25
26fn default_true() -> bool {
27    true
28}
29
30impl LockFile {
31    /// Load lock file from disk. Returns empty lock if file doesn't exist.
32    pub fn load(path: &Path) -> Result<Self, String> {
33        if !path.exists() {
34            return Ok(LockFile::default());
35        }
36        let content =
37            std::fs::read_to_string(path).map_err(|e| format!("Failed to read lock file: {e}"))?;
38        toml::from_str(&content).map_err(|e| format!("Failed to parse lock file: {e}"))
39    }
40
41    /// Save lock file to disk.
42    pub fn save(&self, path: &Path) -> Result<(), String> {
43        let content = toml::to_string_pretty(self)
44            .map_err(|e| format!("Failed to serialize lock file: {e}"))?;
45        let header = "# This file is auto-generated by `tl install`. Do not edit.\n\n";
46        std::fs::write(path, format!("{header}{content}"))
47            .map_err(|e| format!("Failed to write lock file: {e}"))
48    }
49
50    /// Find a locked package by name.
51    pub fn find(&self, name: &str) -> Option<&LockedPackage> {
52        self.packages.iter().find(|p| p.name == name)
53    }
54
55    /// Remove a package by name. Returns true if it was found and removed.
56    pub fn remove(&mut self, name: &str) -> bool {
57        let len_before = self.packages.len();
58        self.packages.retain(|p| p.name != name);
59        self.packages.len() < len_before
60    }
61}
62
63impl LockedPackage {
64    /// Create a new LockedPackage (direct dependency with no transitive deps).
65    pub fn new(name: impl Into<String>, version: impl Into<String>, source: String) -> Self {
66        LockedPackage {
67            name: name.into(),
68            version: version.into(),
69            source,
70            direct: true,
71            dependencies: Vec::new(),
72        }
73    }
74
75    /// Create a source descriptor for a git dependency.
76    pub fn git_source(url: &str, rev: &str) -> String {
77        format!("git+{url}#{rev}")
78    }
79
80    /// Create a source descriptor for a path dependency.
81    pub fn path_source(path: &str) -> String {
82        format!("path+{path}")
83    }
84
85    /// Check if this is a path dependency.
86    pub fn is_path(&self) -> bool {
87        self.source.starts_with("path+")
88    }
89
90    /// Check if this is a git dependency.
91    pub fn is_git(&self) -> bool {
92        self.source.starts_with("git+")
93    }
94
95    /// Extract the path from a path source descriptor.
96    pub fn path_value(&self) -> Option<&str> {
97        self.source.strip_prefix("path+")
98    }
99
100    /// Extract the URL from a git source descriptor.
101    pub fn git_url(&self) -> Option<&str> {
102        self.source
103            .strip_prefix("git+")
104            .and_then(|s| s.split('#').next())
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use tempfile::TempDir;
112
113    #[test]
114    fn lockfile_round_trip() {
115        let dir = TempDir::new().unwrap();
116        let lock_path = dir.path().join("tl.lock");
117
118        let lock = LockFile {
119            packages: vec![
120                LockedPackage::new(
121                    "utils",
122                    "1.0.0",
123                    LockedPackage::path_source("/home/user/utils"),
124                ),
125                LockedPackage::new(
126                    "remote",
127                    "2.1.0",
128                    LockedPackage::git_source("https://github.com/user/remote.git", "abc123"),
129                ),
130            ],
131        };
132
133        lock.save(&lock_path).unwrap();
134        let loaded = LockFile::load(&lock_path).unwrap();
135        assert_eq!(loaded.packages.len(), 2);
136        assert_eq!(loaded.packages[0].name, "utils");
137        assert_eq!(loaded.packages[1].name, "remote");
138    }
139
140    #[test]
141    fn lockfile_find_by_name() {
142        let lock = LockFile {
143            packages: vec![
144                LockedPackage::new("a", "1.0.0", "path+/a".into()),
145                LockedPackage::new("b", "2.0.0", "path+/b".into()),
146            ],
147        };
148        assert!(lock.find("a").is_some());
149        assert!(lock.find("c").is_none());
150    }
151
152    #[test]
153    fn lockfile_load_nonexistent() {
154        let lock = LockFile::load(Path::new("/nonexistent/tl.lock")).unwrap();
155        assert!(lock.packages.is_empty());
156    }
157
158    #[test]
159    fn lockfile_remove() {
160        let mut lock = LockFile {
161            packages: vec![
162                LockedPackage::new("a", "1.0.0", "path+/a".into()),
163                LockedPackage::new("b", "2.0.0", "path+/b".into()),
164            ],
165        };
166        assert!(lock.remove("a"));
167        assert_eq!(lock.packages.len(), 1);
168        assert!(!lock.remove("a"));
169    }
170
171    #[test]
172    fn locked_package_source_helpers() {
173        let pkg = LockedPackage::new(
174            "test",
175            "1.0.0",
176            LockedPackage::git_source("https://example.com/repo.git", "deadbeef"),
177        );
178        assert!(pkg.is_git());
179        assert!(!pkg.is_path());
180        assert_eq!(pkg.git_url(), Some("https://example.com/repo.git"));
181
182        let path_pkg = LockedPackage::new(
183            "local",
184            "0.1.0",
185            LockedPackage::path_source("/home/user/local"),
186        );
187        assert!(path_pkg.is_path());
188        assert_eq!(path_pkg.path_value(), Some("/home/user/local"));
189    }
190
191    #[test]
192    fn lockfile_backward_compat() {
193        // Old format without direct/dependencies fields should deserialize correctly
194        let toml_str = r#"
195[[packages]]
196name = "oldpkg"
197version = "1.0.0"
198source = "path+/old"
199"#;
200        let lock: LockFile = toml::from_str(toml_str).unwrap();
201        assert_eq!(lock.packages.len(), 1);
202        assert!(lock.packages[0].direct); // defaults to true
203        assert!(lock.packages[0].dependencies.is_empty()); // defaults to empty
204    }
205
206    #[test]
207    fn lockfile_round_trip_new_fields() {
208        let dir = TempDir::new().unwrap();
209        let lock_path = dir.path().join("tl.lock");
210
211        let mut pkg = LockedPackage::new("transitive", "1.0.0", "path+/t".into());
212        pkg.direct = false;
213        pkg.dependencies = vec!["sub-dep".into()];
214
215        let lock = LockFile {
216            packages: vec![pkg],
217        };
218        lock.save(&lock_path).unwrap();
219        let loaded = LockFile::load(&lock_path).unwrap();
220        assert_eq!(loaded.packages[0].direct, false);
221        assert_eq!(loaded.packages[0].dependencies, vec!["sub-dep".to_string()]);
222    }
223}