diff --git a/crates/capsule-cli/Cargo.lock b/crates/capsule-cli/Cargo.lock index 81b1c33..c31895e 100644 --- a/crates/capsule-cli/Cargo.lock +++ b/crates/capsule-cli/Cargo.lock @@ -115,6 +115,18 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "ast_node" version = "5.0.0" @@ -176,6 +188,20 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -299,6 +325,7 @@ name = "capsule-core" version = "0.4.0" dependencies = [ "anyhow", + "blake3", "chrono", "colored", "humantime", @@ -444,6 +471,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "core-foundation-sys" version = "0.8.7" diff --git a/crates/capsule-core/Cargo.lock b/crates/capsule-core/Cargo.lock index 21b14d6..4fa3355 100644 --- a/crates/capsule-core/Cargo.lock +++ b/crates/capsule-core/Cargo.lock @@ -74,6 +74,18 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "ast_node" version = "5.0.0" @@ -156,6 +168,20 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -279,6 +305,7 @@ name = "capsule-core" version = "0.4.0" dependencies = [ "anyhow", + "blake3", "chrono", "colored", "humantime", @@ -378,6 +405,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "core-foundation-sys" version = "0.8.7" diff --git a/crates/capsule-core/Cargo.toml b/crates/capsule-core/Cargo.toml index f2450a5..288d9ff 100644 --- a/crates/capsule-core/Cargo.toml +++ b/crates/capsule-core/Cargo.toml @@ -28,6 +28,7 @@ indicatif = "0.17" chrono = "0.4" colored = "2.1" toml = "0.9.11" +blake3 = "1" rustpython-parser = "0.4" swc_ecma_parser = "33.0" diff --git a/crates/capsule-core/src/config/fingerprint.rs b/crates/capsule-core/src/config/fingerprint.rs new file mode 100644 index 0000000..59a9daf --- /dev/null +++ b/crates/capsule-core/src/config/fingerprint.rs @@ -0,0 +1,218 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct SourceFingerprint { + pub files: BTreeMap, +} + +impl SourceFingerprint { + const MANIFEST_FILENAME: &'static str = ".capsule_fingerprint.json"; + + pub fn load(cache_dir: &Path) -> Self { + let fingerprint_path = cache_dir.join(Self::MANIFEST_FILENAME); + + if fingerprint_path.exists() + && let Ok(content) = fs::read_to_string(&fingerprint_path) + && let Ok(fingerprint) = serde_json::from_str(&content) + { + return fingerprint; + } + + Self::default() + } + + pub fn save(&self, cache_dir: &Path) -> std::io::Result<()> { + let fingerprint_path = cache_dir.join(Self::MANIFEST_FILENAME); + let content = serde_json::to_string_pretty(self)?; + fs::write(fingerprint_path, content) + } + + pub fn hash_file(path: &Path) -> Option { + let contents = fs::read(path).ok()?; + Some(blake3::hash(&contents).to_hex().to_string()) + } + + pub fn collect_source_files( + base_dir: &Path, + extensions: &[&str], + ignored_dirs: &[&str], + ) -> Vec { + let mut files = Vec::new(); + Self::collect_files_recursive(base_dir, extensions, ignored_dirs, &mut files); + files + } + + fn collect_files_recursive( + dir: &Path, + extensions: &[&str], + ignored_dirs: &[&str], + files: &mut Vec, + ) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + + for entry in entries.flatten() { + let path = entry.path(); + + if path.is_dir() { + let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + if dir_name.starts_with('.') || ignored_dirs.contains(&dir_name) { + continue; + } + + Self::collect_files_recursive(&path, extensions, ignored_dirs, files); + } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) + && extensions.contains(&ext) + { + files.push(path); + } + } + } + + pub fn build(base_dir: &Path, extensions: &[&str], ignored_dirs: &[&str]) -> Self { + let files = Self::collect_source_files(base_dir, extensions, ignored_dirs); + let mut fingerprint = Self::default(); + + for file_path in files { + if let Some(hash) = Self::hash_file(&file_path) { + let relative = file_path + .strip_prefix(base_dir) + .unwrap_or(&file_path) + .to_string_lossy() + .to_string(); + fingerprint.files.insert(relative, hash); + } + } + + fingerprint + } + + pub fn has_changes(&self, other: &SourceFingerprint) -> bool { + if self.files.len() != other.files.len() { + return true; + } + + for (path, hash) in &self.files { + match other.files.get(path) { + Some(other_hash) if hash == other_hash => continue, + _ => return true, + } + } + + false + } + + pub fn needs_rebuild( + cache_dir: &Path, + source_dir: &Path, + output_path: &Path, + extensions: &[&str], + ignored_dirs: &[&str], + ) -> bool { + if !output_path.exists() { + return true; + } + let previous = Self::load(cache_dir); + + let current = Self::build(source_dir, extensions, ignored_dirs); + + current.has_changes(&previous) + } + + pub fn update_after_build( + cache_dir: &Path, + source_dir: &Path, + extensions: &[&str], + ignored_dirs: &[&str], + ) -> std::io::Result<()> { + let fingerprint = Self::build(source_dir, extensions, ignored_dirs); + fingerprint.save(cache_dir) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash_consistency() { + let hash1 = blake3::hash(b"hello world").to_hex().to_string(); + let hash2 = blake3::hash(b"hello world").to_hex().to_string(); + assert_eq!(hash1, hash2); + + let hash3 = blake3::hash(b"goodbye world").to_hex().to_string(); + assert_ne!(hash1, hash3); + } + + #[test] + fn test_fingerprint_no_changes() { + let mut fingerprint1 = SourceFingerprint::default(); + fingerprint1 + .files + .insert("main.py".to_string(), "abc123".to_string()); + + let mut fingerprint2 = SourceFingerprint::default(); + fingerprint2 + .files + .insert("main.py".to_string(), "abc123".to_string()); + + assert!(!fingerprint1.has_changes(&fingerprint2)); + } + + #[test] + fn test_fingerprint_content_changed() { + let mut fingerprint1 = SourceFingerprint::default(); + fingerprint1 + .files + .insert("main.py".to_string(), "abc123".to_string()); + + let mut fingerprint2 = SourceFingerprint::default(); + fingerprint2 + .files + .insert("main.py".to_string(), "xyz789".to_string()); + + assert!(fingerprint1.has_changes(&fingerprint2)); + } + + #[test] + fn test_fingerprint_file_added() { + let mut fingerprint1 = SourceFingerprint::default(); + fingerprint1 + .files + .insert("main.py".to_string(), "abc123".to_string()); + + let mut fingerprint2 = SourceFingerprint::default(); + fingerprint2 + .files + .insert("main.py".to_string(), "abc123".to_string()); + fingerprint2 + .files + .insert("helper.py".to_string(), "def456".to_string()); + + assert!(fingerprint1.has_changes(&fingerprint2)); + } + + #[test] + fn test_fingerprint_file_deleted() { + let mut fingerprint1 = SourceFingerprint::default(); + fingerprint1 + .files + .insert("main.py".to_string(), "abc123".to_string()); + fingerprint1 + .files + .insert("helper.py".to_string(), "def456".to_string()); + + let mut fingerprint2 = SourceFingerprint::default(); + fingerprint2 + .files + .insert("main.py".to_string(), "abc123".to_string()); + + assert!(fingerprint1.has_changes(&fingerprint2)); + } +} diff --git a/crates/capsule-core/src/config/mod.rs b/crates/capsule-core/src/config/mod.rs index bf23c6b..46745de 100644 --- a/crates/capsule-core/src/config/mod.rs +++ b/crates/capsule-core/src/config/mod.rs @@ -1,3 +1,4 @@ pub mod database; +pub mod fingerprint; pub mod log; pub mod manifest; diff --git a/crates/capsule-core/src/wasm/compiler/javascript.rs b/crates/capsule-core/src/wasm/compiler/javascript.rs index f021120..0e3167d 100644 --- a/crates/capsule-core/src/wasm/compiler/javascript.rs +++ b/crates/capsule-core/src/wasm/compiler/javascript.rs @@ -3,6 +3,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use crate::config::fingerprint::SourceFingerprint; use crate::wasm::utilities::introspection::javascript; use crate::wasm::utilities::wit_manager::WitManager; @@ -97,7 +98,17 @@ impl JavascriptWasmCompiler { } pub fn compile_wasm(&self) -> Result { - if !self.needs_rebuild(&self.source_path, &self.output_wasm)? { + let source_dir = self.source_path.parent().ok_or_else(|| { + JavascriptWasmCompilerError::FsError("Cannot determine source directory".to_string()) + })?; + + if !SourceFingerprint::needs_rebuild( + &self.cache_dir, + source_dir, + &self.output_wasm, + &["js", "ts", "toml"], + &["node_modules", "dist"], + ) { return Ok(self.output_wasm.clone()); } @@ -190,6 +201,13 @@ export const taskRunner = exports; ))); } + let _ = SourceFingerprint::update_after_build( + &self.cache_dir, + source_dir, + &["js", "ts", "toml"], + &["node_modules", "dist"], + ); + Ok(self.output_wasm.clone()) } @@ -244,64 +262,6 @@ export const taskRunner = exports; )) } - fn needs_rebuild( - &self, - source: &Path, - output: &Path, - ) -> Result { - if !output.exists() { - return Ok(true); - } - - let output_time = fs::metadata(output).and_then(|m| m.modified())?; - - let source_time = fs::metadata(source).and_then(|m| m.modified())?; - if source_time > output_time { - return Ok(true); - } - - if let Some(source_dir) = source.parent() - && Self::check_dir_modified(source_dir, source, output_time)? - { - return Ok(true); - } - - Ok(false) - } - - fn check_dir_modified( - dir: &Path, - source: &Path, - wasm_time: std::time::SystemTime, - ) -> Result { - if let Ok(entries) = fs::read_dir(dir) { - for entry in entries.flatten() { - let path = entry.path(); - - if path.is_dir() { - let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if dir_name.starts_with('.') || dir_name == "node_modules" { - continue; - } - - if Self::check_dir_modified(&path, source, wasm_time)? { - return Ok(true); - } - } else if let Some(ext) = path.extension() - && (ext == "js" || ext == "ts") - && path != source - && let Ok(metadata) = fs::metadata(&path) - && let Ok(modified) = metadata.modified() - && modified > wasm_time - { - return Ok(true); - } - } - } - - Ok(false) - } - fn transpile_typescript(&self) -> Result { let output_path = self.cache_dir.join( self.source_path diff --git a/crates/capsule-core/src/wasm/compiler/python.rs b/crates/capsule-core/src/wasm/compiler/python.rs index b16bb50..157c33c 100644 --- a/crates/capsule-core/src/wasm/compiler/python.rs +++ b/crates/capsule-core/src/wasm/compiler/python.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::process::Stdio; +use crate::config::fingerprint::SourceFingerprint; use crate::wasm::utilities::introspection::python; use crate::wasm::utilities::wit_manager::WitManager; @@ -90,7 +91,17 @@ impl PythonWasmCompiler { } pub fn compile_wasm(&self) -> Result { - if !self.needs_rebuild(&self.source_path, &self.output_wasm)? { + let source_dir = self.source_path.parent().ok_or_else(|| { + PythonWasmCompilerError::FsError("Cannot determine source directory".to_string()) + })?; + + if !SourceFingerprint::needs_rebuild( + &self.cache_dir, + source_dir, + &self.output_wasm, + &["py", "toml"], + &["__pycache__"], + ) { return Ok(self.output_wasm.clone()); } @@ -177,64 +188,14 @@ from capsule.app import TaskRunner, exports self.cleanup_pycache(python_path); - Ok(self.output_wasm.clone()) - } - - fn needs_rebuild( - &self, - source: &Path, - wasm_path: &Path, - ) -> Result { - if !wasm_path.exists() { - return Ok(true); - } - - let wasm_time = fs::metadata(wasm_path).and_then(|m| m.modified())?; - - let source_time = fs::metadata(source).and_then(|m| m.modified())?; - if source_time > wasm_time { - return Ok(true); - } - - if let Some(source_dir) = source.parent() - && Self::check_dir_modified(source_dir, source, wasm_time)? - { - return Ok(true); - } - - Ok(false) - } - - fn check_dir_modified( - dir: &Path, - source: &Path, - wasm_time: std::time::SystemTime, - ) -> Result { - if let Ok(entries) = fs::read_dir(dir) { - for entry in entries.flatten() { - let path = entry.path(); - - if path.is_dir() { - let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if dir_name.starts_with('.') || dir_name == "__pycache__" { - continue; - } - - if Self::check_dir_modified(&path, source, wasm_time)? { - return Ok(true); - } - } else if path.extension().is_some_and(|ext| ext == "py") - && path != source - && let Ok(metadata) = fs::metadata(&path) - && let Ok(modified) = metadata.modified() - && modified > wasm_time - { - return Ok(true); - } - } - } + let _ = SourceFingerprint::update_after_build( + &self.cache_dir, + source_dir, + &["py", "toml"], + &["__pycache__"], + ); - Ok(false) + Ok(self.output_wasm.clone()) } fn get_wit_path(&self) -> Result {