use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
use std::{collections::HashMap, path::PathBuf};
use crate::{
config::{load_global_config, load_repo_config},
constants::{APICURIO_CONFIG, APICURIO_LOCK},
dependency::Dependency,
lockfile::{LockFile, LockedDependency},
registry::RegistryClient,
};
pub async fn run() -> Result<()> {
let config_path = PathBuf::from(APICURIO_CONFIG);
let config_content = std::fs::read_to_string(&config_path)
.with_context(|| format!("reading config from {}", config_path.display()))?;
let repo_cfg = load_repo_config(&config_path)?;
let global_cfg = load_global_config()?;
let registries = repo_cfg.merge_registries(global_cfg)?;
let config_hash = LockFile::compute_config_hash(&config_content, &repo_cfg.dependencies);
let mut clients = HashMap::new();
for reg in ®istries {
clients.insert(reg.name.clone(), RegistryClient::new(reg)?);
}
let lock_path = PathBuf::from(APICURIO_LOCK);
let existing_lock = if let Ok(existing_lock) = LockFile::load(&lock_path) {
if existing_lock.is_compatible_with_config(&config_hash) {
if existing_lock
.is_newer_than_config(&config_path)
.unwrap_or(false)
{
if verify_lock_is_still_valid(&existing_lock, &clients).await? {
println!("🔒 Lock file already up-to-date");
return Ok(());
} else {
println!("🔓 Lock file outdated: some dependencies are no longer available");
}
} else {
println!("🔓 Lock file outdated: config file has been modified");
}
} else {
println!("🔓 Lock file outdated: config hash changed");
}
Some(existing_lock)
} else {
None
};
let mut new_locks = Vec::with_capacity(repo_cfg.dependencies.len());
for dep_cfg in &repo_cfg.dependencies {
let dep = Dependency::from_config(dep_cfg)?;
let client = &clients[&dep.registry];
let all_versions = client
.list_versions(&dep.group_id, &dep.artifact_id)
.await
.with_context(|| {
format!("listing versions for {}/{}", dep.group_id, dep.artifact_id)
})?;
let selected = all_versions
.iter()
.filter(|v| dep.req.matches(v))
.max()
.with_context(|| {
format!(
"no version matching '{}' for dependency '{}'",
dep_cfg.version, dep_cfg.name
)
})?;
let data = client
.download(&dep.group_id, &dep.artifact_id, selected)
.await
.with_context(|| format!("downloading content for {} v{}", dep_cfg.name, selected))?;
let sha256 = {
let mut hasher = Sha256::new();
hasher.update(&data);
hex::encode(hasher.finalize())
};
new_locks.push(LockedDependency {
name: dep_cfg.name.clone(),
registry: dep.registry.clone(),
resolved_version: selected.to_string(),
download_url: client.get_download_url(&dep.group_id, &dep.artifact_id, selected),
sha256,
output_path: dep.output_path.clone(),
group_id: dep.group_id.clone(),
artifact_id: dep.artifact_id.clone(),
version_spec: dep_cfg.version.clone(),
});
}
let config_modified = LockFile::get_config_modification_time(&config_path).ok();
let lf = LockFile::with_config_modified(new_locks, config_hash, config_modified);
if let Some(ref old_lock) = existing_lock {
cleanup_changed_output_paths(&old_lock.locked_dependencies, &lf.locked_dependencies)?;
}
lf.save(&lock_path)
.with_context(|| format!("writing {}", lock_path.display()))?;
println!("🔒 Updated {}", lock_path.display());
Ok(())
}
async fn verify_lock_is_still_valid(
lock: &LockFile,
clients: &HashMap<String, RegistryClient>,
) -> Result<bool> {
if let Ok(generated_nanos) = lock.generated_at.parse::<i64>() {
let now_nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0);
let five_minutes_nanos = 5 * 60 * 1_000_000_000i64; if now_nanos.saturating_sub(generated_nanos) < five_minutes_nanos {
return Ok(true);
}
}
for locked_dep in &lock.locked_dependencies {
let client = match clients.get(&locked_dep.registry) {
Some(c) => c,
None => {
eprintln!(
"Warning: Registry '{}' is no longer configured",
locked_dep.registry
);
return Ok(false);
}
};
match client
.list_versions(&locked_dep.group_id, &locked_dep.artifact_id)
.await
{
Ok(versions) => {
if !versions
.iter()
.any(|v| v.to_string() == locked_dep.resolved_version)
{
eprintln!(
"Warning: Version '{}' of '{}:{}' is no longer available",
locked_dep.resolved_version, locked_dep.group_id, locked_dep.artifact_id
);
return Ok(false);
}
}
Err(e) => {
eprintln!(
"Warning: Failed to check availability of '{}:{}': {}",
locked_dep.group_id, locked_dep.artifact_id, e
);
return Ok(false);
}
}
}
Ok(true)
}
fn cleanup_changed_output_paths(
old_dependencies: &[LockedDependency],
new_dependencies: &[LockedDependency],
) -> Result<()> {
use std::collections::HashMap;
let old_paths: HashMap<&str, &str> = old_dependencies
.iter()
.map(|dep| (dep.name.as_str(), dep.output_path.as_str()))
.collect();
let new_paths: HashMap<&str, &str> = new_dependencies
.iter()
.map(|dep| (dep.name.as_str(), dep.output_path.as_str()))
.collect();
for (dep_name, old_path) in &old_paths {
if let Some(new_path) = new_paths.get(dep_name) {
if old_path != new_path {
let old_file = PathBuf::from(old_path);
if old_file.exists() {
match std::fs::remove_file(&old_file) {
Ok(()) => {
println!("🗑️ Removed old output file: {old_path}");
}
Err(e) => {
eprintln!(
"Warning: Failed to remove old output file '{old_path}': {e}"
);
}
}
if let Some(parent) = old_file.parent() {
let _ = remove_empty_parent_dirs(parent);
}
}
}
} else {
let old_file = PathBuf::from(old_path);
if old_file.exists() {
match std::fs::remove_file(&old_file) {
Ok(()) => {
println!(
"🗑️ Removed output file for removed dependency '{dep_name}': {old_path}"
);
}
Err(e) => {
eprintln!(
"Warning: Failed to remove output file for removed dependency '{dep_name}': {e}"
);
}
}
if let Some(parent) = old_file.parent() {
let _ = remove_empty_parent_dirs(parent);
}
}
}
}
Ok(())
}
fn remove_empty_parent_dirs(dir: &std::path::Path) -> Result<()> {
let cwd = std::env::current_dir().unwrap_or_default();
if dir == cwd || dir.parent().is_none() {
return Ok(());
}
if let Ok(mut entries) = std::fs::read_dir(dir) {
if entries.next().is_none() {
match std::fs::remove_dir(dir) {
Ok(()) => {
println!("🗑️ Removed empty directory: {}", dir.display());
if let Some(parent) = dir.parent() {
let _ = remove_empty_parent_dirs(parent);
}
}
Err(_) => {
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use tokio;
#[test]
fn test_verify_lock_is_still_valid_with_missing_registry() {
let mut lock = LockFile::new(vec![], "test_hash".to_string());
lock.generated_at = "1000000000000000000".to_string(); lock.locked_dependencies.push(LockedDependency {
name: "test_dep".to_string(),
registry: "missing_registry".to_string(),
resolved_version: "1.0.0".to_string(),
download_url: "https://example.com/test".to_string(),
sha256: "test_hash".to_string(),
output_path: "./protos".to_string(),
group_id: "com.example".to_string(),
artifact_id: "test".to_string(),
version_spec: "^1.0".to_string(),
});
let clients = HashMap::new();
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(verify_lock_is_still_valid(&lock, &clients));
assert!(result.is_ok());
assert!(
!result.unwrap(),
"Should return false when registry is missing"
);
}
#[test]
fn test_cleanup_changed_output_paths() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
let old_path = temp_path.join("old").join("types.proto");
let new_path = temp_path.join("new").join("types.proto");
fs::create_dir_all(old_path.parent().unwrap()).unwrap();
fs::write(&old_path, "old content").unwrap();
let old_deps = vec![LockedDependency {
name: "test_dep".to_string(),
registry: "local".to_string(),
resolved_version: "1.0.0".to_string(),
download_url: "http://localhost/test".to_string(),
sha256: "test_hash".to_string(),
output_path: old_path.to_string_lossy().to_string(),
group_id: "com.example".to_string(),
artifact_id: "test".to_string(),
version_spec: "^1.0".to_string(),
}];
let new_deps = vec![LockedDependency {
name: "test_dep".to_string(),
registry: "local".to_string(),
resolved_version: "1.0.0".to_string(),
download_url: "http://localhost/test".to_string(),
sha256: "test_hash".to_string(),
output_path: new_path.to_string_lossy().to_string(),
group_id: "com.example".to_string(),
artifact_id: "test".to_string(),
version_spec: "^1.0".to_string(),
}];
assert!(old_path.exists());
cleanup_changed_output_paths(&old_deps, &new_deps).unwrap();
assert!(!old_path.exists());
assert!(!old_path.parent().unwrap().exists());
}
#[test]
fn test_cleanup_removed_dependency() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
let old_path = temp_path.join("removed").join("types.proto");
fs::create_dir_all(old_path.parent().unwrap()).unwrap();
fs::write(&old_path, "old content").unwrap();
let old_deps = vec![LockedDependency {
name: "removed_dep".to_string(),
registry: "local".to_string(),
resolved_version: "1.0.0".to_string(),
download_url: "http://localhost/test".to_string(),
sha256: "test_hash".to_string(),
output_path: old_path.to_string_lossy().to_string(),
group_id: "com.example".to_string(),
artifact_id: "test".to_string(),
version_spec: "^1.0".to_string(),
}];
let new_deps = vec![];
assert!(old_path.exists());
cleanup_changed_output_paths(&old_deps, &new_deps).unwrap();
assert!(!old_path.exists());
assert!(!old_path.parent().unwrap().exists());
}
#[test]
fn test_cleanup_unchanged_output_paths() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
let file_path = temp_path.join("unchanged").join("types.proto");
fs::create_dir_all(file_path.parent().unwrap()).unwrap();
fs::write(&file_path, "content").unwrap();
let deps = vec![LockedDependency {
name: "unchanged_dep".to_string(),
registry: "local".to_string(),
resolved_version: "1.0.0".to_string(),
download_url: "http://localhost/test".to_string(),
sha256: "test_hash".to_string(),
output_path: file_path.to_string_lossy().to_string(),
group_id: "com.example".to_string(),
artifact_id: "test".to_string(),
version_spec: "^1.0".to_string(),
}];
assert!(file_path.exists());
cleanup_changed_output_paths(&deps, &deps).unwrap();
assert!(file_path.exists());
}
}