use std::collections::HashMap;
use std::fs::File;
use std::path::{Path, PathBuf};
use colored::Colorize;
use flate2::Compression;
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tar::{Archive, Builder};
use walkdir::WalkDir;
use crate::config::RemoteCacheConfig;
use crate::error::{ForgeError, ForgeResult};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BuildCache {
pub version: u32,
pub file_hashes: HashMap<String, String>,
pub last_build_timestamp: Option<u64>,
}
impl BuildCache {
pub fn load(project_dir: &Path) -> ForgeResult<Self> {
let cache_path = Self::cache_path(project_dir);
if !cache_path.exists() {
return Ok(Self {
version: 1,
..Default::default()
});
}
let content = std::fs::read_to_string(&cache_path).map_err(|e| ForgeError::IoError {
path: cache_path.clone(),
message: e.to_string(),
})?;
serde_json::from_str(&content).map_err(|_| ForgeError::CacheCorrupted {
path: cache_path,
}.into())
}
pub fn save(&self, project_dir: &Path) -> ForgeResult<()> {
let forge_dir = project_dir.join(".forge");
std::fs::create_dir_all(&forge_dir).map_err(|e| ForgeError::IoError {
path: forge_dir.clone(),
message: e.to_string(),
})?;
let cache_path = Self::cache_path(project_dir);
let content = serde_json::to_string_pretty(self).map_err(|e| ForgeError::IoError {
path: cache_path.clone(),
message: e.to_string(),
})?;
std::fs::write(&cache_path, content).map_err(|e| ForgeError::IoError {
path: cache_path,
message: e.to_string(),
})?;
Ok(())
}
pub fn has_changes(&self, source_dir: &Path, extensions: &[&str]) -> ForgeResult<bool> {
let current_hashes = Self::compute_hashes(source_dir, extensions)?;
for (path, hash) in ¤t_hashes {
match self.file_hashes.get(path) {
Some(old_hash) if old_hash == hash => continue,
_ => return Ok(true), }
}
for old_path in self.file_hashes.keys() {
if !current_hashes.contains_key(old_path) {
return Ok(true);
}
}
Ok(false)
}
pub fn update_hashes(&mut self, source_dir: &Path, extensions: &[&str]) -> ForgeResult<()> {
self.file_hashes = Self::compute_hashes(source_dir, extensions)?;
self.last_build_timestamp = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
);
Ok(())
}
pub fn changed_files(&self, source_dir: &Path, extensions: &[&str]) -> ForgeResult<Vec<PathBuf>> {
let current_hashes = Self::compute_hashes(source_dir, extensions)?;
let mut changed = Vec::new();
for (path, hash) in ¤t_hashes {
match self.file_hashes.get(path) {
Some(old_hash) if old_hash == hash => continue,
_ => changed.push(PathBuf::from(path)),
}
}
Ok(changed)
}
pub fn clean(project_dir: &Path) -> ForgeResult<()> {
let forge_dir = project_dir.join(".forge");
if forge_dir.exists() {
std::fs::remove_dir_all(&forge_dir).map_err(|e| ForgeError::IoError {
path: forge_dir,
message: e.to_string(),
})?;
}
Ok(())
}
pub async fn upload_to_remote(
&self,
project_dir: &Path,
output_dir_name: &str,
remote_config: &RemoteCacheConfig,
) -> ForgeResult<()> {
if !remote_config.push {
return Ok(());
}
let master_hash = self.compute_master_hash()?;
let archive_name = format!("{}.tar.gz", master_hash);
let remote_url = format!("{}/cache/{}", remote_config.remote.trim_end_matches('/'), archive_name);
println!(" {} Subiendo build al caché distribuido ({})", "⬆️".cyan(), master_hash);
let output_path = project_dir.join(output_dir_name);
if !output_path.exists() {
return Ok(());
}
let tar_gz_path = std::env::temp_dir().join(&archive_name);
let tar_gz_file = File::create(&tar_gz_path).map_err(|e| ForgeError::IoError {
path: tar_gz_path.clone(),
message: e.to_string(),
})?;
let enc = GzEncoder::new(tar_gz_file, Compression::default());
let mut tar = Builder::new(enc);
tar.append_dir_all(".", &output_path).map_err(|e| ForgeError::IoError {
path: output_path.clone(),
message: format!("Error al comprimir caché: {}", e),
})?;
tar.into_inner().unwrap().finish().unwrap();
let client = Client::new();
let mut req = client.put(&remote_url);
if let Some(token) = &remote_config.token {
req = req.bearer_auth(token);
}
let file_bytes = std::fs::read(&tar_gz_path).unwrap();
let res: Result<reqwest::Response, reqwest::Error> = req.body(file_bytes).send().await;
let _ = std::fs::remove_file(&tar_gz_path);
match res {
Ok(resp) if resp.status().is_success() => {
println!(" {} Caché remoto actualizado exitosamente", "✅".green());
Ok(())
}
Ok(resp) => {
eprintln!(" {} Fallo al subir caché ({})", "⚠️".yellow(), resp.status());
Ok(()) }
Err(e) => {
eprintln!(" {} Fallo red al subir caché: {}", "⚠️".yellow(), e);
Ok(())
}
}
}
pub async fn download_from_remote(
&self,
project_dir: &Path,
output_dir_name: &str,
remote_config: &RemoteCacheConfig,
) -> ForgeResult<bool> {
let master_hash = self.compute_master_hash()?;
let archive_name = format!("{}.tar.gz", master_hash);
let remote_url = format!("{}/cache/{}", remote_config.remote.trim_end_matches('/'), archive_name);
let client = Client::new();
let mut req = client.get(&remote_url);
if let Some(token) = &remote_config.token {
req = req.bearer_auth(token);
}
let res: Result<reqwest::Response, reqwest::Error> = req.send().await;
match res {
Ok(resp) if resp.status().is_success() => {
println!(" {} Caché distribuido encontrado ({})", "☁️".cyan(), master_hash);
let bytes = resp.bytes().await.unwrap();
let output_path = project_dir.join(output_dir_name);
if output_path.exists() {
let _ = std::fs::remove_dir_all(&output_path);
}
std::fs::create_dir_all(&output_path).unwrap();
let tar_gz = std::io::Cursor::new(bytes);
let tar = GzDecoder::new(tar_gz);
let mut archive = Archive::new(tar);
if let Err(e) = archive.unpack(&output_path) {
eprintln!(" {} Error extrayendo caché: {}", "⚠️".yellow(), e);
return Ok(false);
}
println!(" {} Caché remoto restaurado en {}", "⚡".green(), output_dir_name);
return Ok(true);
}
_ => {
Ok(false)
}
}
}
pub fn compute_master_hash(&self) -> ForgeResult<String> {
let mut hasher = Sha256::new();
let mut sorted_keys: Vec<&String> = self.file_hashes.keys().collect();
sorted_keys.sort();
for key in sorted_keys {
if let Some(hash) = self.file_hashes.get(key) {
hasher.update(key.as_bytes());
hasher.update(hash.as_bytes());
}
}
Ok(format!("{:x}", hasher.finalize()))
}
fn cache_path(project_dir: &Path) -> PathBuf {
project_dir.join(".forge").join("cache.json")
}
fn compute_hashes(
source_dir: &Path,
extensions: &[&str],
) -> ForgeResult<HashMap<String, String>> {
let mut hashes = HashMap::new();
if !source_dir.exists() {
return Ok(hashes);
}
for entry in WalkDir::new(source_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
let path = entry.path();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
if !extensions.is_empty() && !extensions.contains(&ext) {
continue;
}
let content = std::fs::read(path).map_err(|e| ForgeError::IoError {
path: path.to_path_buf(),
message: e.to_string(),
})?;
let mut hasher = Sha256::new();
hasher.update(&content);
let hash = format!("{:x}", hasher.finalize());
let relative = path
.strip_prefix(source_dir)
.unwrap_or(path)
.to_string_lossy()
.to_string();
hashes.insert(relative, hash);
}
Ok(hashes)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_cache_empty() {
let cache = BuildCache::default();
assert!(cache.file_hashes.is_empty());
assert_eq!(cache.version, 0);
}
#[test]
fn test_compute_hashes() {
let temp_dir = std::env::temp_dir().join("forge_test_cache");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
fs::write(temp_dir.join("test.java"), "public class Test {}").unwrap();
fs::write(temp_dir.join("other.txt"), "ignorar").unwrap();
let hashes = BuildCache::compute_hashes(&temp_dir, &["java"]).unwrap();
assert_eq!(hashes.len(), 1);
assert!(hashes.contains_key("test.java"));
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_detect_changes() {
let temp_dir = std::env::temp_dir().join("forge_test_changes");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
fs::write(temp_dir.join("Main.java"), "class Main {}").unwrap();
let mut cache = BuildCache {
version: 1,
..Default::default()
};
assert!(cache.has_changes(&temp_dir, &["java"]).unwrap());
cache.update_hashes(&temp_dir, &["java"]).unwrap();
assert!(!cache.has_changes(&temp_dir, &["java"]).unwrap());
fs::write(temp_dir.join("Main.java"), "class Main { int x; }").unwrap();
assert!(cache.has_changes(&temp_dir, &["java"]).unwrap());
let _ = fs::remove_dir_all(&temp_dir);
}
}