diff --git a/crates/hammer-build/src/lib.rs b/crates/hammer-build/src/lib.rs index ed2b010e..a37f9fdf 100644 --- a/crates/hammer-build/src/lib.rs +++ b/crates/hammer-build/src/lib.rs @@ -19,16 +19,69 @@ pub use swm_bridge::build_source_patch; /// Calcula el `ArtifactHash` de una receta, resolviendo recursivamente sus deps de build. /// Ver `docs/02-build-lab.md` §2 y §5. +/// +/// **Catálogo = `base_dir` de la receta.** Cada nombre en `deps.build` se resuelve al hermano +/// `/.toml`; su hash entra en el de la receta (Merkle sobre el subgrafo de build). +/// Así dos recetas con la misma fuente pero distinto grafo de deps hashean distinto, y cambiar +/// una dep transitiva invalida la caché de todo lo que la usa. +/// +/// Errores: una receta con `deps.build` pero sin `base_dir` (parseada de TOML crudo, no cargada +/// de disco) no puede resolver su catálogo; un `.toml` ausente, o un ciclo de deps, también +/// fallan con un mensaje que nombra la cadena. pub fn artifact_hash(recipe: &Recipe, _store: &Store) -> hammer_core::Result { - let dep_hashes: Vec = Vec::new(); - for _dep in &recipe.deps.build { - // TODO(fase-0): cargar la receta de la dep y recursar. + let mut stack: Vec = Vec::new(); + artifact_hash_rec(recipe, &mut stack) +} + +fn artifact_hash_rec(recipe: &Recipe, stack: &mut Vec) -> hammer_core::Result { + if stack.iter().any(|n| n == &recipe.name) { + stack.push(recipe.name.clone()); + return Err(hammer_core::Error::Recipe(format!( + "ciclo de dependencias de build: {}", + stack.join(" -> ") + ))); } + + let dep_hashes = resolve_build_dep_hashes(recipe, stack)?; let inputs = recipe.hash_inputs(&dep_hashes)?; let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect(); Ok(ArtifactHash::of_inputs(&refs)) } +/// Resuelve y hashea, en orden de declaración, cada `deps.build` de `recipe` cargándolo del +/// catálogo (su `base_dir`). `stack` lleva la cadena en curso para detectar ciclos. +fn resolve_build_dep_hashes( + recipe: &Recipe, + stack: &mut Vec, +) -> hammer_core::Result> { + if recipe.deps.build.is_empty() { + return Ok(Vec::new()); + } + if recipe.base_dir.as_os_str().is_empty() { + return Err(hammer_core::Error::Recipe(format!( + "la receta '{}' declara deps de build {:?} pero no tiene base_dir; \ + cárgala con Recipe::load_from_path para resolver el catálogo", + recipe.name, recipe.deps.build + ))); + } + + stack.push(recipe.name.clone()); + let mut hashes = Vec::with_capacity(recipe.deps.build.len()); + for dep in &recipe.deps.build { + let dep_path = recipe.base_dir.join(format!("{dep}.toml")); + let dep_recipe = Recipe::load_from_path(&dep_path).map_err(|e| { + hammer_core::Error::Recipe(format!( + "no pude cargar la dep de build '{dep}' de '{}' ({}): {e}", + recipe.name, + dep_path.display() + )) + })?; + hashes.push(artifact_hash_rec(&dep_recipe, stack)?); + } + stack.pop(); + Ok(hashes) +} + /// Compila una receta y devuelve el hash del artefacto sellado en el store. /// Si el hash ya existe en el store, devuelve sin recompilar (caché). pub fn build( @@ -324,3 +377,103 @@ pub fn hydrate( let artifact_dir = store.find_by_hash(h.as_str())?; hydrate::hydrate(&artifact_dir, target_fhs, mode) } + +#[cfg(test)] +mod dep_hash_tests { + use super::*; + + /// Escribe `/.toml` con `source.commit = commit` y, si hay, `deps.build = deps`. + fn write_recipe(dir: &Path, name: &str, commit: &str, deps: &[&str]) { + let deps_block = if deps.is_empty() { + String::new() + } else { + let list = deps.iter().map(|d| format!("\"{d}\"")).collect::>().join(", "); + format!("\n[deps]\nbuild = [{list}]\n") + }; + let toml = format!( + "name = \"{name}\"\nversion = \"1\"\n[source]\nrepo = \"git://{name}\"\ncommit = \"{commit}\"\n[build]\n{deps_block}" + ); + std::fs::write(dir.join(format!("{name}.toml")), toml).unwrap(); + } + + fn load(dir: &Path, name: &str) -> Recipe { + Recipe::load_from_path(dir.join(format!("{name}.toml"))).unwrap() + } + + fn store_in(dir: &Path) -> Store { + Store::open(dir.join("store")).unwrap() + } + + #[test] + fn dep_contributes_to_hash() { + let d = tempfile::tempdir().unwrap(); + let store = store_in(d.path()); + write_recipe(d.path(), "base", "b0", &[]); + write_recipe(d.path(), "app", "a0", &["base"]); + write_recipe(d.path(), "app_nodep", "a0", &[]); // misma fuente, sin la dep + + let with_dep = artifact_hash(&load(d.path(), "app"), &store).unwrap(); + let no_dep = artifact_hash(&load(d.path(), "app_nodep"), &store).unwrap(); + // `app_nodep` se llama distinto, pero el nombre no entra al hash: la única diferencia + // de entrada es la presencia de la dep. (Renombramos sólo para tener dos .toml.) + assert_ne!(with_dep, no_dep, "la dep de build debe entrar al hash"); + } + + #[test] + fn transitive_dep_change_propagates() { + let d = tempfile::tempdir().unwrap(); + let store = store_in(d.path()); + write_recipe(d.path(), "base", "b0", &[]); + write_recipe(d.path(), "lib", "l0", &["base"]); + write_recipe(d.path(), "app", "a0", &["lib"]); + let before = artifact_hash(&load(d.path(), "app"), &store).unwrap(); + + // Cambiar la fuente de la dep transitiva `base` debe re-hashear `app`. + write_recipe(d.path(), "base", "b1", &[]); + let after = artifact_hash(&load(d.path(), "app"), &store).unwrap(); + assert_ne!(before, after, "un cambio transitivo debe propagarse"); + } + + #[test] + fn missing_dep_toml_errors_with_names() { + let d = tempfile::tempdir().unwrap(); + let store = store_in(d.path()); + write_recipe(d.path(), "app", "a0", &["pcre2"]); // sin pcre2.toml + let err = artifact_hash(&load(d.path(), "app"), &store).unwrap_err().to_string(); + assert!(err.contains("pcre2"), "el error debe nombrar la dep: {err}"); + assert!(err.contains("app"), "y la receta que la pide: {err}"); + } + + #[test] + fn cycle_is_detected() { + let d = tempfile::tempdir().unwrap(); + let store = store_in(d.path()); + write_recipe(d.path(), "a", "a0", &["b"]); + write_recipe(d.path(), "b", "b0", &["a"]); + let err = artifact_hash(&load(d.path(), "a"), &store).unwrap_err().to_string(); + assert!(err.contains("ciclo"), "esperaba ciclo: {err}"); + assert!(err.contains("a -> b -> a"), "debe mostrar la cadena: {err}"); + } + + #[test] + fn deps_without_base_dir_errors() { + let store = Store::open(tempfile::tempdir().unwrap().path().join("store")).unwrap(); + // Parseada de TOML crudo ⇒ base_dir vacío; con deps no hay catálogo que resolver. + let r = Recipe::from_toml( + "name=\"x\"\nversion=\"1\"\n[source]\nrepo=\"git://x\"\ncommit=\"c0\"\n[build]\n[deps]\nbuild=[\"y\"]\n", + ) + .unwrap(); + let err = artifact_hash(&r, &store).unwrap_err().to_string(); + assert!(err.contains("base_dir"), "debe explicar la falta de base_dir: {err}"); + } + + #[test] + fn no_deps_unaffected() { + let d = tempfile::tempdir().unwrap(); + let store = store_in(d.path()); + write_recipe(d.path(), "solo", "s0", &[]); + let a = artifact_hash(&load(d.path(), "solo"), &store).unwrap(); + let b = artifact_hash(&load(d.path(), "solo"), &store).unwrap(); + assert_eq!(a, b, "sin deps el hash es estable"); + } +}