1use serde::{Deserialize, Serialize};
2use std::path::Path;
3
4#[derive(Debug, Clone, Serialize, Deserialize, Default)]
6pub struct LockFile {
7 #[serde(default)]
8 pub packages: Vec<LockedPackage>,
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13pub struct LockedPackage {
14 pub name: String,
15 pub version: String,
16 pub source: String,
18 #[serde(default = "default_true")]
20 pub direct: bool,
21 #[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 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 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 pub fn find(&self, name: &str) -> Option<&LockedPackage> {
52 self.packages.iter().find(|p| p.name == name)
53 }
54
55 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 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 pub fn git_source(url: &str, rev: &str) -> String {
77 format!("git+{url}#{rev}")
78 }
79
80 pub fn path_source(path: &str) -> String {
82 format!("path+{path}")
83 }
84
85 pub fn is_path(&self) -> bool {
87 self.source.starts_with("path+")
88 }
89
90 pub fn is_git(&self) -> bool {
92 self.source.starts_with("git+")
93 }
94
95 pub fn path_value(&self) -> Option<&str> {
97 self.source.strip_prefix("path+")
98 }
99
100 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 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); assert!(lock.packages[0].dependencies.is_empty()); }
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}