use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LockFile {
#[serde(default)]
pub packages: Vec<LockedPackage>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockedPackage {
pub name: String,
pub version: String,
pub source: String,
#[serde(default = "default_true")]
pub direct: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dependencies: Vec<String>,
}
fn default_true() -> bool {
true
}
impl LockFile {
pub fn load(path: &Path) -> Result<Self, String> {
if !path.exists() {
return Ok(LockFile::default());
}
let content =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read lock file: {e}"))?;
toml::from_str(&content).map_err(|e| format!("Failed to parse lock file: {e}"))
}
pub fn save(&self, path: &Path) -> Result<(), String> {
let content = toml::to_string_pretty(self)
.map_err(|e| format!("Failed to serialize lock file: {e}"))?;
let header = "# This file is auto-generated by `tl install`. Do not edit.\n\n";
std::fs::write(path, format!("{header}{content}"))
.map_err(|e| format!("Failed to write lock file: {e}"))
}
pub fn find(&self, name: &str) -> Option<&LockedPackage> {
self.packages.iter().find(|p| p.name == name)
}
pub fn remove(&mut self, name: &str) -> bool {
let len_before = self.packages.len();
self.packages.retain(|p| p.name != name);
self.packages.len() < len_before
}
}
impl LockedPackage {
pub fn new(name: impl Into<String>, version: impl Into<String>, source: String) -> Self {
LockedPackage {
name: name.into(),
version: version.into(),
source,
direct: true,
dependencies: Vec::new(),
}
}
pub fn git_source(url: &str, rev: &str) -> String {
format!("git+{url}#{rev}")
}
pub fn path_source(path: &str) -> String {
format!("path+{path}")
}
pub fn is_path(&self) -> bool {
self.source.starts_with("path+")
}
pub fn is_git(&self) -> bool {
self.source.starts_with("git+")
}
pub fn path_value(&self) -> Option<&str> {
self.source.strip_prefix("path+")
}
pub fn git_url(&self) -> Option<&str> {
self.source
.strip_prefix("git+")
.and_then(|s| s.split('#').next())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn lockfile_round_trip() {
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join("tl.lock");
let lock = LockFile {
packages: vec![
LockedPackage::new(
"utils",
"1.0.0",
LockedPackage::path_source("/home/user/utils"),
),
LockedPackage::new(
"remote",
"2.1.0",
LockedPackage::git_source("https://github.com/user/remote.git", "abc123"),
),
],
};
lock.save(&lock_path).unwrap();
let loaded = LockFile::load(&lock_path).unwrap();
assert_eq!(loaded.packages.len(), 2);
assert_eq!(loaded.packages[0].name, "utils");
assert_eq!(loaded.packages[1].name, "remote");
}
#[test]
fn lockfile_find_by_name() {
let lock = LockFile {
packages: vec![
LockedPackage::new("a", "1.0.0", "path+/a".into()),
LockedPackage::new("b", "2.0.0", "path+/b".into()),
],
};
assert!(lock.find("a").is_some());
assert!(lock.find("c").is_none());
}
#[test]
fn lockfile_load_nonexistent() {
let lock = LockFile::load(Path::new("/nonexistent/tl.lock")).unwrap();
assert!(lock.packages.is_empty());
}
#[test]
fn lockfile_remove() {
let mut lock = LockFile {
packages: vec![
LockedPackage::new("a", "1.0.0", "path+/a".into()),
LockedPackage::new("b", "2.0.0", "path+/b".into()),
],
};
assert!(lock.remove("a"));
assert_eq!(lock.packages.len(), 1);
assert!(!lock.remove("a"));
}
#[test]
fn locked_package_source_helpers() {
let pkg = LockedPackage::new(
"test",
"1.0.0",
LockedPackage::git_source("https://example.com/repo.git", "deadbeef"),
);
assert!(pkg.is_git());
assert!(!pkg.is_path());
assert_eq!(pkg.git_url(), Some("https://example.com/repo.git"));
let path_pkg = LockedPackage::new(
"local",
"0.1.0",
LockedPackage::path_source("/home/user/local"),
);
assert!(path_pkg.is_path());
assert_eq!(path_pkg.path_value(), Some("/home/user/local"));
}
#[test]
fn lockfile_backward_compat() {
let toml_str = r#"
[[packages]]
name = "oldpkg"
version = "1.0.0"
source = "path+/old"
"#;
let lock: LockFile = toml::from_str(toml_str).unwrap();
assert_eq!(lock.packages.len(), 1);
assert!(lock.packages[0].direct); assert!(lock.packages[0].dependencies.is_empty()); }
#[test]
fn lockfile_round_trip_new_fields() {
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join("tl.lock");
let mut pkg = LockedPackage::new("transitive", "1.0.0", "path+/t".into());
pkg.direct = false;
pkg.dependencies = vec!["sub-dep".into()];
let lock = LockFile {
packages: vec![pkg],
};
lock.save(&lock_path).unwrap();
let loaded = LockFile::load(&lock_path).unwrap();
assert_eq!(loaded.packages[0].direct, false);
assert_eq!(loaded.packages[0].dependencies, vec!["sub-dep".to_string()]);
}
}