use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{fs, path::Path};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct LockedDependency {
pub name: String,
pub registry: String,
pub resolved_version: String,
pub download_url: String,
pub sha256: String,
pub output_path: String,
pub group_id: String,
pub artifact_id: String,
pub version_spec: String,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct LockFile {
pub locked_dependencies: Vec<LockedDependency>,
pub lockfile_version: u32,
pub config_hash: String,
pub generated_at: String,
pub config_modified: Option<String>,
}
impl LockFile {
pub fn load(path: &Path) -> anyhow::Result<Self> {
let data = fs::read_to_string(path)?;
let lf: LockFile = serde_yaml::from_str(&data)?;
Ok(lf)
}
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
let data = serde_yaml::to_string(self)?;
fs::write(path, data)?;
Ok(())
}
#[allow(dead_code)]
pub fn new(locked_dependencies: Vec<LockedDependency>, config_hash: String) -> Self {
Self::with_config_modified(locked_dependencies, config_hash, None)
}
pub fn with_config_modified(
locked_dependencies: Vec<LockedDependency>,
config_hash: String,
config_modified: Option<String>,
) -> Self {
let now = chrono::Utc::now()
.timestamp_nanos_opt()
.unwrap_or(0)
.to_string();
Self {
locked_dependencies,
lockfile_version: 1,
config_hash,
generated_at: now,
config_modified,
}
}
pub fn is_compatible_with_config(&self, config_hash: &str) -> bool {
self.config_hash == config_hash
}
pub fn is_newer_than_config(&self, config_path: &Path) -> anyhow::Result<bool> {
if let Some(config_modified_str) = &self.config_modified {
if let Ok(config_modified_nanos) = config_modified_str.parse::<i64>() {
if let Ok(metadata) = fs::metadata(config_path) {
if let Ok(actual_modified) = metadata.modified() {
let actual_nanos = chrono::DateTime::<chrono::Utc>::from(actual_modified)
.timestamp_nanos_opt()
.unwrap_or(0);
return Ok(config_modified_nanos >= actual_nanos);
}
}
}
}
Ok(false)
}
#[allow(dead_code)]
pub fn is_up_to_date(
&self,
config_path: &Path,
current_config_hash: &str,
dependencies: &[LockedDependency],
) -> anyhow::Result<bool> {
if !self.is_compatible_with_config(current_config_hash) {
return Ok(false);
}
if !self.is_newer_than_config(config_path)? {
return Ok(false);
}
if !self.dependencies_match(dependencies) {
return Ok(false);
}
Ok(true)
}
#[allow(dead_code)]
pub fn dependencies_match(&self, other_deps: &[LockedDependency]) -> bool {
if self.locked_dependencies.len() != other_deps.len() {
return false;
}
let self_map: std::collections::HashMap<&str, &LockedDependency> = self
.locked_dependencies
.iter()
.map(|d| (d.name.as_str(), d))
.collect();
let other_map: std::collections::HashMap<&str, &LockedDependency> =
other_deps.iter().map(|d| (d.name.as_str(), d)).collect();
self_map.len() == other_map.len()
&& self_map.iter().all(|(name, dep)| {
other_map
.get(name)
.is_some_and(|other_dep| **dep == **other_dep)
})
}
pub fn compute_config_hash(
config_content: &str,
dependencies: &[crate::config::DependencyConfig],
) -> String {
let mut hasher = Sha256::new();
let mut dep_specs: Vec<String> = dependencies
.iter()
.map(|d| {
format!(
"{}:{}:{}:{}:{}:{}",
d.name, d.group_id, d.artifact_id, d.version, d.registry, d.output_path
)
})
.collect();
dep_specs.sort();
for spec in dep_specs {
hasher.update(spec.as_bytes());
}
if let Ok(config) = serde_yaml::from_str::<crate::config::RepoConfig>(config_content) {
let mut registry_specs: Vec<String> = config
.registries
.iter()
.map(|r| format!("{}:{}", r.name, r.url))
.collect();
registry_specs.sort();
for spec in registry_specs {
hasher.update(spec.as_bytes());
}
if let Some(ext_file) = &config.external_registries_file {
hasher.update(ext_file.as_bytes());
}
}
hex::encode(hasher.finalize())
}
pub fn get_config_modification_time(config_path: &Path) -> anyhow::Result<String> {
let metadata = fs::metadata(config_path)?;
let modified = metadata.modified()?;
let nanos = chrono::DateTime::<chrono::Utc>::from(modified)
.timestamp_nanos_opt()
.unwrap_or(0);
Ok(nanos.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_config(dependencies: &[(&str, &str, &str, &str, &str, &str)]) -> String {
let mut deps = String::new();
for (name, group_id, artifact_id, version, registry, output_path) in dependencies {
deps.push_str(&format!(
r#"
- name: "{name}"
groupId: "{group_id}"
artifactId: "{artifact_id}"
version: "{version}"
registry: "{registry}"
outputPath: "{output_path}"
"#
));
}
format!(
r#"externalRegistriesFile: null
registries: []
dependencies:{deps}"#
)
}
fn create_test_locked_dependency(
name: &str,
registry: &str,
resolved_version: &str,
group_id: &str,
artifact_id: &str,
version_spec: &str,
) -> LockedDependency {
LockedDependency {
name: name.to_string(),
registry: registry.to_string(),
resolved_version: resolved_version.to_string(),
download_url: format!(
"https://example.com/{group_id}/{artifact_id}/{resolved_version}"
),
sha256: "dummy_hash".to_string(),
output_path: "./protos".to_string(),
group_id: group_id.to_string(),
artifact_id: artifact_id.to_string(),
version_spec: version_spec.to_string(),
}
}
#[test]
fn test_config_hash_computation() {
let config1 = create_test_config(&[(
"dep1",
"com.example",
"service1",
"1.0.0",
"registry1",
"./protos",
)]);
let config2 = create_test_config(&[(
"dep1",
"com.example",
"service1",
"1.0.0",
"registry1",
"./protos",
)]);
let config3 = create_test_config(&[(
"dep1",
"com.example",
"service1",
"1.1.0",
"registry1",
"./protos",
)]);
use crate::config::DependencyConfig;
let deps1 = vec![DependencyConfig {
name: "dep1".to_string(),
group_id: "com.example".to_string(),
artifact_id: "service1".to_string(),
version: "1.0.0".to_string(),
registry: "registry1".to_string(),
output_path: "./protos".to_string(),
}];
let deps3 = vec![DependencyConfig {
name: "dep1".to_string(),
group_id: "com.example".to_string(),
artifact_id: "service1".to_string(),
version: "1.1.0".to_string(),
registry: "registry1".to_string(),
output_path: "./protos".to_string(),
}];
let hash1 = LockFile::compute_config_hash(&config1, &deps1);
let hash2 = LockFile::compute_config_hash(&config2, &deps1);
let hash3 = LockFile::compute_config_hash(&config3, &deps3);
assert_eq!(hash1, hash2, "Same config should produce same hash");
assert_ne!(
hash1, hash3,
"Different config should produce different hash"
);
}
#[test]
fn test_dependencies_match_order_independence() {
let dep1 = create_test_locked_dependency(
"dep1",
"reg1",
"1.0.0",
"com.example",
"service1",
"^1.0",
);
let dep2 = create_test_locked_dependency(
"dep2",
"reg1",
"2.0.0",
"com.example",
"service2",
"^2.0",
);
let deps_order1 = vec![dep1.clone(), dep2.clone()];
let deps_order2 = vec![dep2.clone(), dep1.clone()];
let lockfile = LockFile::new(deps_order1.clone(), "test_hash".to_string());
assert!(lockfile.dependencies_match(&deps_order1));
assert!(
lockfile.dependencies_match(&deps_order2),
"Order should not matter"
);
}
#[test]
fn test_dependencies_match_different_content() {
let dep1 = create_test_locked_dependency(
"dep1",
"reg1",
"1.0.0",
"com.example",
"service1",
"^1.0",
);
let dep2 = create_test_locked_dependency(
"dep2",
"reg1",
"2.0.0",
"com.example",
"service2",
"^2.0",
);
let dep1_modified = create_test_locked_dependency(
"dep1",
"reg1",
"1.1.0",
"com.example",
"service1",
"^1.0",
);
let deps1 = vec![dep1.clone(), dep2.clone()];
let deps2 = vec![dep1_modified, dep2.clone()];
let lockfile = LockFile::new(deps1.clone(), "test_hash".to_string());
assert!(lockfile.dependencies_match(&deps1));
assert!(
!lockfile.dependencies_match(&deps2),
"Different versions should not match"
);
}
#[test]
fn test_config_compatibility() {
let dep1 = create_test_locked_dependency(
"dep1",
"reg1",
"1.0.0",
"com.example",
"service1",
"^1.0",
);
let lockfile = LockFile::new(vec![dep1], "test_hash".to_string());
assert!(lockfile.is_compatible_with_config("test_hash"));
assert!(!lockfile.is_compatible_with_config("different_hash"));
}
#[test]
fn test_lockfile_serialization() {
let dep1 = create_test_locked_dependency(
"dep1",
"reg1",
"1.0.0",
"com.example",
"service1",
"^1.0",
);
let lockfile = LockFile::new(vec![dep1], "test_hash".to_string());
let serialized = serde_yaml::to_string(&lockfile).unwrap();
let deserialized: LockFile = serde_yaml::from_str(&serialized).unwrap();
assert_eq!(lockfile.config_hash, deserialized.config_hash);
assert_eq!(lockfile.lockfile_version, deserialized.lockfile_version);
assert_eq!(
lockfile.locked_dependencies.len(),
deserialized.locked_dependencies.len()
);
assert!(lockfile.dependencies_match(&deserialized.locked_dependencies));
}
#[test]
fn test_empty_dependencies() {
let lockfile = LockFile::new(vec![], "test_hash".to_string());
assert!(lockfile.dependencies_match(&[]));
assert!(
!lockfile.dependencies_match(&[create_test_locked_dependency(
"dep1",
"reg1",
"1.0.0",
"com.example",
"service1",
"^1.0"
)])
);
}
#[test]
fn test_missing_dependency() {
let dep1 = create_test_locked_dependency(
"dep1",
"reg1",
"1.0.0",
"com.example",
"service1",
"^1.0",
);
let dep2 = create_test_locked_dependency(
"dep2",
"reg1",
"2.0.0",
"com.example",
"service2",
"^2.0",
);
let lockfile = LockFile::new(vec![dep1.clone(), dep2.clone()], "test_hash".to_string());
assert!(!lockfile.dependencies_match(&[dep1])); assert!(!lockfile.dependencies_match(&[dep2])); }
#[test]
fn test_config_hash_deterministic_ordering() {
let deps1 = vec![
crate::config::DependencyConfig {
name: "dep_a".to_string(),
group_id: "com.example".to_string(),
artifact_id: "service_a".to_string(),
version: "1.0.0".to_string(),
registry: "registry1".to_string(),
output_path: "./protos".to_string(),
},
crate::config::DependencyConfig {
name: "dep_b".to_string(),
group_id: "com.example".to_string(),
artifact_id: "service_b".to_string(),
version: "2.0.0".to_string(),
registry: "registry1".to_string(),
output_path: "./protos".to_string(),
},
];
let deps2 = vec![deps1[1].clone(), deps1[0].clone()];
let config_content = "test config";
let hash1 = LockFile::compute_config_hash(config_content, &deps1);
let hash2 = LockFile::compute_config_hash(config_content, &deps2);
assert_eq!(hash1, hash2, "Config hash should be order-independent");
}
#[test]
fn test_enhanced_config_hash_ignores_formatting() {
let deps = vec![crate::config::DependencyConfig {
name: "dep1".to_string(),
group_id: "com.example".to_string(),
artifact_id: "service1".to_string(),
version: "1.0.0".to_string(),
registry: "registry1".to_string(),
output_path: "./protos".to_string(),
}];
let config1 = r#"
externalRegistriesFile: null
registries: []
dependencies:
- name: dep1
groupId: com.example
artifactId: service1
version: "1.0.0"
registry: registry1
outputPath: ./protos
"#;
let config2 = r#"
externalRegistriesFile: null
registries: []
# This is a comment
dependencies:
- name: dep1
groupId: com.example
artifactId: service1
version: "1.0.0"
registry: registry1
outputPath: ./protos
# Another comment
"#;
let hash1 = LockFile::compute_config_hash(config1, &deps);
let hash2 = LockFile::compute_config_hash(config2, &deps);
assert_eq!(
hash1, hash2,
"Config hash should ignore comments and formatting"
);
}
#[test]
fn test_with_config_modified() {
let dep1 = create_test_locked_dependency(
"dep1",
"reg1",
"1.0.0",
"com.example",
"service1",
"^1.0",
);
let config_modified = Some("1234567890123456789".to_string());
let lockfile = LockFile::with_config_modified(
vec![dep1],
"test_hash".to_string(),
config_modified.clone(),
);
assert_eq!(lockfile.config_modified, config_modified);
assert!(lockfile.generated_at.parse::<i64>().is_ok());
}
#[test]
fn test_is_newer_than_config_with_missing_data() {
let dep1 = create_test_locked_dependency(
"dep1",
"reg1",
"1.0.0",
"com.example",
"service1",
"^1.0",
);
let lockfile = LockFile::new(vec![dep1.clone()], "test_hash".to_string());
let result = lockfile
.is_newer_than_config(Path::new("nonexistent"))
.unwrap();
assert!(
!result,
"Should return false when config_modified is missing"
);
let mut lockfile_invalid = LockFile::new(vec![dep1], "test_hash".to_string());
lockfile_invalid.config_modified = Some("invalid_number".to_string());
let result = lockfile_invalid
.is_newer_than_config(Path::new("nonexistent"))
.unwrap();
assert!(
!result,
"Should return false when config_modified is invalid"
);
}
#[test]
fn test_lockfile_backwards_compatibility() {
let old_lockfile_yaml = r#"
lockfileVersion: 1
configHash: "test_hash"
generatedAt: "1234567890"
lockedDependencies:
- name: "dep1"
registry: "reg1"
resolvedVersion: "1.0.0"
downloadUrl: "https://example.com/dep1"
sha256: "dummy_hash"
outputPath: "./protos"
groupId: "com.example"
artifactId: "service1"
versionSpec: "^1.0"
"#;
let lockfile: LockFile = serde_yaml::from_str(old_lockfile_yaml).unwrap();
assert!(lockfile.config_modified.is_none());
assert_eq!(lockfile.config_hash, "test_hash");
assert_eq!(lockfile.locked_dependencies.len(), 1);
}
#[test]
fn test_robust_dependency_matching() {
let dep1_v1 = create_test_locked_dependency(
"dep1",
"reg1",
"1.0.0",
"com.example",
"service1",
"^1.0",
);
let dep1_v2 = create_test_locked_dependency(
"dep1",
"reg1",
"1.0.1",
"com.example",
"service1",
"^1.0",
);
let dep2 = create_test_locked_dependency(
"dep2",
"reg1",
"2.0.0",
"com.example",
"service2",
"^2.0",
);
let lockfile = LockFile::new(vec![dep1_v1.clone(), dep2.clone()], "test_hash".to_string());
assert!(lockfile.dependencies_match(&[dep1_v1.clone(), dep2.clone()]));
assert!(lockfile.dependencies_match(&[dep2.clone(), dep1_v1.clone()]));
assert!(!lockfile.dependencies_match(&[dep1_v2, dep2.clone()]));
assert!(!lockfile.dependencies_match(&[dep1_v1.clone()]));
let dep3 = create_test_locked_dependency(
"dep3",
"reg1",
"3.0.0",
"com.example",
"service3",
"^3.0",
);
assert!(!lockfile.dependencies_match(&[dep1_v1.clone(), dep2.clone(), dep3]));
}
}