//! La `Recipe`: descripción pura de un build. Ver `docs/02-build-lab.md` §1. use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::hash::ArtifactHash; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Recipe { pub name: String, pub version: String, pub source: Source, pub build: Build, #[serde(default)] pub deps: Deps, /// Directorio base contra el que se resuelven rutas relativas de la receta /// (típicamente, `patches`). Lo fija `load_from_path`; al deserializar puro queda vacío. #[serde(skip, default)] pub base_dir: PathBuf, } /// `Source` admite dos modos, mutuamente excluyentes, validados en runtime: /// /// 1. **Git** — `repo` + `commit` (SHA fijado, nunca HEAD; ADR 0006). Apropiado para /// upstreams cuyo árbol de git ya es buildable o cuando queremos pinear un parche que /// aún no salió en release. /// 2. **Tarball** — `tarball` (URL) + `sha256`. Apropiado para releases estables: la /// mayoría de proyectos GNU/autotools traen `configure` ya generado y embebidos los /// submódulos (gnulib, etc.), evitando la necesidad de `./bootstrap` en el sandbox. /// /// El hash de entrada del artefacto usa `commit` o `sha256` según el modo, ambos /// identificadores inmutables del contenido fuente. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Source { // --- modo git --- #[serde(default, skip_serializing_if = "Option::is_none")] pub repo: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub commit: Option, // --- modo tarball --- #[serde(default, skip_serializing_if = "Option::is_none")] pub tarball: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub sha256: Option, /// Cuántos componentes recortar al extraer el tarball. Default 1 (los releases GNU /// siempre tienen un único top-level `proyecto-version/`). #[serde(default = "default_strip")] pub strip_components: usize, // --- común --- #[serde(default)] pub patches: Vec, } fn default_strip() -> usize { 1 } /// Discriminante validado del `Source`. Se calcula bajo demanda; los campos de /// `Source` permanecen flat en TOML para que las recetas sean legibles. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SourceKind<'a> { Git { repo: &'a str, commit: &'a str }, Tarball { url: &'a str, sha256: &'a str }, } impl Source { pub fn kind(&self) -> crate::Result> { match ( self.repo.as_deref(), self.commit.as_deref(), self.tarball.as_deref(), self.sha256.as_deref(), ) { (Some(repo), Some(commit), None, None) => Ok(SourceKind::Git { repo, commit }), (None, None, Some(url), Some(sha256)) => Ok(SourceKind::Tarball { url, sha256 }), (Some(_), Some(_), Some(_), _) | (Some(_), Some(_), _, Some(_)) => Err( crate::Error::Recipe( "source: usa repo+commit O tarball+sha256, no ambos".into(), ), ), _ => Err(crate::Error::Recipe( "source: faltan campos; necesito (repo+commit) o (tarball+sha256)".into(), )), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Build { #[serde(default)] pub compiler: Compiler, #[serde(default = "default_target")] pub target: String, #[serde(default)] pub link: LinkMode, #[serde(default)] pub flags: Vec, /// Overrides explícitos de las fases del build. Lo no especificado se decide por /// heurística en el lab (ver `docs/02-build-lab.md` §4). #[serde(default)] pub phases: Phases, } /// Comandos shell que se ejecutan en el sandbox por cada fase. Cualquiera puede ser `None`: /// el lab lo deriva por heurística (autotools / cmake / meson / make). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Phases { #[serde(default, skip_serializing_if = "Option::is_none")] pub configure: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub compile: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub install: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Deps { #[serde(default)] pub build: Vec, #[serde(default)] pub runtime: Vec, } /// Compilador del lab, POR RECETA (no global). `zig-cc` por defecto; escotilla a clang/gcc /// para paquetes con gcc-ismos. Ver ADR 0003. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum Compiler { #[default] ZigCc, Clang, Gcc, } impl Compiler { pub fn as_str(&self) -> &'static str { match self { Compiler::ZigCc => "zig-cc", Compiler::Clang => "clang", Compiler::Gcc => "gcc", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum LinkMode { #[default] Static, Dynamic, } impl LinkMode { pub fn as_str(&self) -> &'static str { match self { LinkMode::Static => "static", LinkMode::Dynamic => "dynamic", } } } fn default_target() -> String { "x86_64-linux-musl".to_string() } impl Recipe { /// Parsea una receta desde su forma TOML. `base_dir` queda vacío: si la receta declara /// `patches`, el caller debe fijar `base_dir` antes de hashear, o usar `load_from_path`. pub fn from_toml(s: &str) -> crate::Result { toml::from_str(s).map_err(|e| crate::Error::Recipe(e.to_string())) } /// Serializa la receta a TOML. Útil para el sidecar de provenance que `hammer-build` /// escribe junto al artefacto en el store. `base_dir` no se serializa (es transient). pub fn to_toml(&self) -> crate::Result { toml::to_string(self).map_err(|e| crate::Error::Recipe(e.to_string())) } /// Carga una receta desde el disco. `base_dir` se fija al directorio que contiene el /// archivo, de modo que `source.patches` resuelve relativo a ahí (estilo Cargo). pub fn load_from_path(path: impl AsRef) -> crate::Result { let path = path.as_ref(); let text = std::fs::read_to_string(path)?; let mut recipe = Recipe::from_toml(&text)?; if let Some(parent) = path.parent() { recipe.base_dir = parent.to_path_buf(); } Ok(recipe) } /// Las entradas canónicas que alimentan el `ArtifactHash` de esta receta. Incluye el /// **contenido** de cada patch (no su ruta), de modo que renombrar un archivo no cambia el /// hash y editarlo sí lo hace. Ver `docs/02-build-lab.md` §2. pub fn hash_inputs( &self, dep_hashes: &[ArtifactHash], ) -> crate::Result>> { // El identificador inmutable del contenido fuente: el commit en modo git, el // sha256 del archivo en modo tarball. Cualquiera de los dos es un puntero al // contenido exacto, lo que basta para reproducibilidad. let source_id = match self.source.kind()? { SourceKind::Git { commit, .. } => format!("git:{commit}"), SourceKind::Tarball { sha256, .. } => format!("tarball:{sha256}"), }; let mut v: Vec> = vec![ source_id.into_bytes(), self.build.compiler.as_str().as_bytes().to_vec(), self.build.target.as_bytes().to_vec(), self.build.link.as_str().as_bytes().to_vec(), ]; for p in &self.source.patches { let resolved = self.base_dir.join(p); let bytes = std::fs::read(&resolved).map_err(|e| { crate::Error::Recipe(format!("no pude leer patch {}: {e}", resolved.display())) })?; v.push(bytes); } for f in &self.build.flags { v.push(f.as_bytes().to_vec()); } // Phases override: si alguien cambia el comando del compile, el artefacto cambia. // Etiquetamos cada uno para que añadir un override luego no colisione con un flag. for (label, val) in [ ("phase:configure", &self.build.phases.configure), ("phase:compile", &self.build.phases.compile), ("phase:install", &self.build.phases.install), ] { if let Some(cmd) = val { v.push(format!("{label}={cmd}").into_bytes()); } } for d in dep_hashes { v.push(d.as_str().as_bytes().to_vec()); } Ok(v) } } #[cfg(test)] mod tests { use super::*; const SAMPLE: &str = r#" name = "grep" version = "3.11" [source] repo = "git://git.savannah.gnu.org/grep.git" commit = "a1b2c3d4e5f6" [build] compiler = "zig-cc" target = "x86_64-linux-musl" link = "static" flags = ["--enable-perl-regexp"] [deps] build = ["pcre2"] "#; #[test] fn parse_full() { let r = Recipe::from_toml(SAMPLE).expect("parse"); assert_eq!(r.name, "grep"); assert!(matches!( r.source.kind().unwrap(), SourceKind::Git { commit: "a1b2c3d4e5f6", .. } )); assert_eq!(r.build.compiler, Compiler::ZigCc); assert_eq!(r.build.link, LinkMode::Static); assert_eq!(r.deps.build, vec!["pcre2".to_string()]); } #[test] fn to_toml_roundtrips_via_from_toml() { let r1 = Recipe::from_toml(SAMPLE).unwrap(); let serialized = r1.to_toml().expect("to_toml"); let r2 = Recipe::from_toml(&serialized).expect("from_toml(roundtrip)"); assert_eq!(r2.name, r1.name); assert_eq!(r2.version, r1.version); assert_eq!(r2.build.compiler, r1.build.compiler); assert_eq!(r2.build.link, r1.build.link); assert_eq!(r2.build.flags, r1.build.flags); assert_eq!(r2.deps.build, r1.deps.build); // El kind del source también roundtripea. match (r1.source.kind().unwrap(), r2.source.kind().unwrap()) { (SourceKind::Git { commit: c1, .. }, SourceKind::Git { commit: c2, .. }) => { assert_eq!(c1, c2); } _ => panic!("source kind cambió en el roundtrip"), } } #[test] fn parse_tarball_source() { let s = r#" name = "grep" version = "3.11" [source] tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz" sha256 = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb" [build] "#; let r = Recipe::from_toml(s).expect("parse"); assert!(matches!(r.source.kind().unwrap(), SourceKind::Tarball { .. })); assert_eq!(r.source.strip_components, 1); } #[test] fn source_mixed_modes_rejected() { let s = r#" name = "x" version = "0" [source] repo = "git://x" commit = "deadbeef" tarball = "https://x/a.tar.gz" sha256 = "abc" [build] "#; let r = Recipe::from_toml(s).unwrap(); let err = r.source.kind().unwrap_err().to_string(); assert!(err.contains("no ambos"), "msg = {err}"); } #[test] fn source_missing_fields_rejected() { let s = r#" name = "x" version = "0" [source] repo = "git://x" [build] "#; let r = Recipe::from_toml(s).unwrap(); let err = r.source.kind().unwrap_err().to_string(); assert!(err.contains("faltan campos"), "msg = {err}"); } #[test] fn tarball_and_git_hash_to_different_values() { let git = Recipe::from_toml(SAMPLE).unwrap(); let tar_toml = r#" name = "grep" version = "3.11" [source] tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz" sha256 = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb" [build] flags = ["--enable-perl-regexp"] "#; let tar = Recipe::from_toml(tar_toml).unwrap(); assert_ne!(git.hash_inputs(&[]).unwrap(), tar.hash_inputs(&[]).unwrap()); } #[test] fn parse_minimal_uses_defaults() { let s = r#" name = "x" version = "0" [source] repo = "git://x" commit = "deadbeef" [build] "#; let r = Recipe::from_toml(s).expect("parse"); assert_eq!(r.build.compiler, Compiler::ZigCc); // default assert_eq!(r.build.target, "x86_64-linux-musl"); // default assert_eq!(r.build.link, LinkMode::Static); // default assert!(r.deps.build.is_empty()); } #[test] fn hash_no_patches_is_deterministic() { let r = Recipe::from_toml(SAMPLE).unwrap(); let a = r.hash_inputs(&[]).unwrap(); let b = r.hash_inputs(&[]).unwrap(); assert_eq!(a, b); } #[test] fn hash_uses_patch_contents_not_path() { let dir1 = tempfile::tempdir().unwrap(); let dir2 = tempfile::tempdir().unwrap(); std::fs::write(dir1.path().join("p.patch"), b"diff --content").unwrap(); std::fs::write(dir2.path().join("otro-nombre.patch"), b"diff --content").unwrap(); let mut r1 = Recipe::from_toml(SAMPLE).unwrap(); r1.source.patches = vec!["p.patch".into()]; r1.base_dir = dir1.path().to_path_buf(); let mut r2 = Recipe::from_toml(SAMPLE).unwrap(); r2.source.patches = vec!["otro-nombre.patch".into()]; r2.base_dir = dir2.path().to_path_buf(); // Mismo CONTENIDO ⇒ misma entrada al hash, aunque las rutas sean distintas. assert_eq!(r1.hash_inputs(&[]).unwrap(), r2.hash_inputs(&[]).unwrap()); } #[test] fn hash_changes_when_patch_content_changes() { let dir = tempfile::tempdir().unwrap(); let patch = dir.path().join("p.patch"); std::fs::write(&patch, b"version A").unwrap(); let mut r = Recipe::from_toml(SAMPLE).unwrap(); r.source.patches = vec!["p.patch".into()]; r.base_dir = dir.path().to_path_buf(); let h_a = r.hash_inputs(&[]).unwrap(); std::fs::write(&patch, b"version B").unwrap(); let h_b = r.hash_inputs(&[]).unwrap(); assert_ne!(h_a, h_b); } #[test] fn missing_patch_reports_path() { let mut r = Recipe::from_toml(SAMPLE).unwrap(); r.source.patches = vec!["no-existe.patch".into()]; r.base_dir = PathBuf::from("/tmp/seguro-que-no-existe-hammer"); let err = r.hash_inputs(&[]).unwrap_err().to_string(); assert!(err.contains("no-existe.patch"), "msg = {err}"); } #[test] fn load_from_path_sets_base_dir() { let dir = tempfile::tempdir().unwrap(); let recipe_path = dir.path().join("foo.toml"); std::fs::write(&recipe_path, SAMPLE).unwrap(); let r = Recipe::load_from_path(&recipe_path).unwrap(); assert_eq!(r.base_dir, dir.path()); } }