Files
hammer/crates/hammer-core/src/recipe.rs
T
sergio c583c4d679 Etapa G: frontera go.mod-en-subdir — knob [build] subdir (vendor/detect/compile en src/<subdir>)
Destraba proyectos Go cuyo módulo no está en la raíz (dolt→go, csvtk→subdir): vendor_go_deps corre en
el subdir, detect_build_system/detect_go_main operan ahí, y el compile hace 'cd <subdir>' primero.
Test go_subdir_compiles_in_subdir.
2026-06-29 15:06:35 -04:00

476 lines
17 KiB
Rust

//! 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
// --- modo tarball ---
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tarball: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
/// 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<String>,
}
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<SourceKind<'_>> {
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<String>,
/// Versión de zig a usar para ESTA receta, distinta del zig por defecto del lab. Sirve de
/// escotilla por-receta para esquivar la regresión de zig 0.14 (que miscompila ciertos binarios
/// musl dinámicos: flex/cmake/python/binutils/…): `zig_version = "0.13.0"` los construye con el
/// zig bueno sin tocar el toolchain global ni el baseline de reproducibilidad (estas piezas son
/// herramientas, no inputs del of_tree del 4/4). `None` ⇒ el zig por defecto. Ver SDD 11 §7.2b.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub zig_version: Option<String>,
/// Habilita CGO en un build Go (`BuildSys::Go`). Por defecto el lab compila con `CGO_ENABLED=0`
/// (Go puro, estático, sin C). Algunos proyectos necesitan cgo para drivers con C bundled (p.ej.
/// el driver sqlite3 de mattn en usql/sq/dolt): con `cgo = true` el compile usa `CGO_ENABLED=1`
/// + `CC="zig cc"` (musl nativo del sandbox) y liga estático (`-linkmode=external -extldflags=
/// -static`). Sólo para C bundled; cgo contra libs C EXTERNAS (gpgme/libvirt) requiere además
/// esas recetas en el sandbox. No-op fuera de BuildSys::Go.
#[serde(default)]
pub cgo: bool,
/// Subdirectorio donde vive el módulo Go cuando NO está en la raíz del repo (p.ej. dolt → `go`,
/// csvtk → su subdir). El lab vendorea, detecta el main y compila DENTRO de `<src>/<subdir>`.
/// Vacío = raíz (lo normal). Sólo aplica a BuildSys::Go.
#[serde(default)]
pub subdir: String,
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub install: Option<String>,
}
impl Phases {
/// `true` si ninguna fase está sobreescrita (todo se decide por heurística). Sirve a
/// serde (`skip_serializing_if`) para que un `.swm` sin fases custom no las emita.
pub fn is_empty(&self) -> bool {
self.configure.is_none() && self.compile.is_none() && self.install.is_none()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Deps {
#[serde(default)]
pub build: Vec<String>,
#[serde(default)]
pub runtime: Vec<String>,
}
impl Deps {
/// `true` si no hay ninguna dep (ni build ni runtime). Sirve a serde para no emitir el
/// bloque `deps` vacío en un `.swm`/receta.
pub fn is_empty(&self) -> bool {
self.build.is_empty() && self.runtime.is_empty()
}
}
/// 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<Recipe> {
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<String> {
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<Path>) -> crate::Result<Recipe> {
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<Vec<Vec<u8>>> {
// 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<u8>> = 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(),
];
// Sólo entra al hash si está fijado: una receta sin `zig_version` mantiene su hash de antes
// de existir el campo (compatibilidad hacia atrás; el baseline 9adefb82 no se mueve).
if let Some(zv) = &self.build.zig_version {
v.push(format!("zig:{zv}").into_bytes());
}
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());
}
}