use crate::error::PackageError;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LockFile {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub packages: Vec<LockedPackage>,
}
fn default_version() -> u32 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockedPackage {
pub name: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub git: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rev: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dependencies: Vec<String>,
}
impl LockedPackage {
pub fn git(
name: String,
version: String,
git: String,
rev: String,
dependencies: Vec<String>,
) -> Self {
Self {
name,
version,
git: Some(git),
rev: Some(rev),
path: None,
dependencies,
}
}
pub fn path(name: String, version: String, path: String, dependencies: Vec<String>) -> Self {
Self {
name,
version,
git: None,
rev: None,
path: Some(path),
dependencies,
}
}
pub fn is_path(&self) -> bool {
self.path.is_some()
}
pub fn is_git(&self) -> bool {
self.git.is_some()
}
}
impl LockFile {
pub fn load(path: &Path) -> Result<Self, PackageError> {
let contents = std::fs::read_to_string(path).map_err(|e| PackageError::IoError {
message: format!("failed to read {}", path.display()),
source: e,
})?;
toml::from_str(&contents).map_err(|e| PackageError::InvalidLockFile { source: e })
}
pub fn save(&self, path: &Path) -> Result<(), PackageError> {
let header = "# This file is auto-generated by Grove. Do not edit manually.\n\n";
let contents = toml::to_string_pretty(self).map_err(|e| PackageError::IoError {
message: format!("failed to serialize lock file: {e}"),
source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
})?;
std::fs::write(path, format!("{header}{contents}")).map_err(|e| PackageError::IoError {
message: format!("failed to write {}", path.display()),
source: e,
})?;
Ok(())
}
pub fn is_empty(&self) -> bool {
self.packages.is_empty()
}
pub fn find(&self, name: &str) -> Option<&LockedPackage> {
self.packages.iter().find(|p| p.name == name)
}
pub fn package_map(&self) -> HashMap<&str, &LockedPackage> {
self.packages.iter().map(|p| (p.name.as_str(), p)).collect()
}
pub fn matches_dependencies(&self, deps: &HashMap<String, crate::DependencySpec>) -> bool {
use crate::DependencySpec;
for (name, spec) in deps {
match self.find(name) {
Some(locked) => match spec {
DependencySpec::Git(g) => {
if locked.git.as_ref() != Some(&g.git) {
return false;
}
}
DependencySpec::Path(p) => {
if locked.path.as_ref() != Some(&p.path) {
return false;
}
}
},
None => return false,
}
}
true
}
pub fn in_dependency_order(&self) -> Vec<&LockedPackage> {
let mut result = Vec::new();
let mut visited = std::collections::HashSet::new();
let pkg_map: HashMap<&str, &LockedPackage> = self.package_map();
fn visit<'a>(
pkg: &'a LockedPackage,
pkg_map: &HashMap<&str, &'a LockedPackage>,
visited: &mut std::collections::HashSet<&'a str>,
result: &mut Vec<&'a LockedPackage>,
) {
if visited.contains(pkg.name.as_str()) {
return;
}
visited.insert(&pkg.name);
for dep_name in &pkg.dependencies {
if let Some(dep) = pkg_map.get(dep_name.as_str()) {
visit(dep, pkg_map, visited, result);
}
}
result.push(pkg);
}
for pkg in &self.packages {
visit(pkg, &pkg_map, &mut visited, &mut result);
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serialize_lock_file() {
let lock = LockFile {
version: 1,
packages: vec![
LockedPackage::git(
"foo".to_string(),
"1.0.0".to_string(),
"https://github.com/example/foo".to_string(),
"abc123def456".to_string(),
vec![],
),
LockedPackage::git(
"bar".to_string(),
"2.0.0".to_string(),
"https://github.com/example/bar".to_string(),
"789xyz".to_string(),
vec!["foo".to_string()],
),
],
};
let serialized = toml::to_string_pretty(&lock).unwrap();
assert!(serialized.contains("name = \"foo\""));
assert!(serialized.contains("name = \"bar\""));
assert!(serialized.contains("dependencies = [\"foo\"]"));
}
#[test]
fn serialize_path_dependency() {
let lock = LockFile {
version: 1,
packages: vec![LockedPackage::path(
"local-lib".to_string(),
"0.1.0".to_string(),
"../my-local-lib".to_string(),
vec![],
)],
};
let serialized = toml::to_string_pretty(&lock).unwrap();
assert!(serialized.contains("name = \"local-lib\""));
assert!(serialized.contains("path = \"../my-local-lib\""));
assert!(!serialized.contains("git ="));
assert!(!serialized.contains("rev ="));
}
#[test]
fn deserialize_lock_file() {
let toml_str = r#"
version = 1
[[packages]]
name = "foo"
version = "1.0.0"
git = "https://github.com/example/foo"
rev = "abc123"
[[packages]]
name = "bar"
version = "2.0.0"
git = "https://github.com/example/bar"
rev = "def456"
dependencies = ["foo"]
"#;
let lock: LockFile = toml::from_str(toml_str).unwrap();
assert_eq!(lock.packages.len(), 2);
assert_eq!(lock.packages[0].name, "foo");
assert_eq!(lock.packages[1].dependencies, vec!["foo"]);
}
#[test]
fn deserialize_path_dependency() {
let toml_str = r#"
version = 1
[[packages]]
name = "local"
version = "0.1.0"
path = "../local-lib"
"#;
let lock: LockFile = toml::from_str(toml_str).unwrap();
assert_eq!(lock.packages.len(), 1);
assert!(lock.packages[0].is_path());
assert_eq!(lock.packages[0].path, Some("../local-lib".to_string()));
}
#[test]
fn find_package() {
let lock = LockFile {
version: 1,
packages: vec![LockedPackage::git(
"test".to_string(),
"1.0.0".to_string(),
"https://example.com/test".to_string(),
"abc".to_string(),
vec![],
)],
};
assert!(lock.find("test").is_some());
assert!(lock.find("nonexistent").is_none());
}
#[test]
fn dependency_order() {
let lock = LockFile {
version: 1,
packages: vec![
LockedPackage::git(
"c".to_string(),
"1.0.0".to_string(),
"https://example.com/c".to_string(),
"ccc".to_string(),
vec!["a".to_string(), "b".to_string()],
),
LockedPackage::git(
"a".to_string(),
"1.0.0".to_string(),
"https://example.com/a".to_string(),
"aaa".to_string(),
vec![],
),
LockedPackage::git(
"b".to_string(),
"1.0.0".to_string(),
"https://example.com/b".to_string(),
"bbb".to_string(),
vec!["a".to_string()],
),
],
};
let ordered = lock.in_dependency_order();
let names: Vec<&str> = ordered.iter().map(|p| p.name.as_str()).collect();
let a_pos = names.iter().position(|&n| n == "a").unwrap();
let b_pos = names.iter().position(|&n| n == "b").unwrap();
let c_pos = names.iter().position(|&n| n == "c").unwrap();
assert!(a_pos < b_pos);
assert!(a_pos < c_pos);
assert!(b_pos < c_pos);
}
#[test]
fn locked_package_helpers() {
let git_pkg = LockedPackage::git(
"foo".to_string(),
"1.0.0".to_string(),
"https://example.com".to_string(),
"abc123".to_string(),
vec![],
);
assert!(git_pkg.is_git());
assert!(!git_pkg.is_path());
let path_pkg = LockedPackage::path(
"bar".to_string(),
"0.1.0".to_string(),
"../bar".to_string(),
vec![],
);
assert!(path_pkg.is_path());
assert!(!path_pkg.is_git());
}
}