sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
⎇ 09d50f9e60c9f523e99aff3d6c8dcf2e5b3cd1a5
crates/hammer-build/src/lib.rs
Historia
Quién tocó qué
crudo
Fase 4 — formato .swm: verify, apply (config_edit/file_drop/source_patch) y export
1//! El laboratorio: compila una receta de forma hermética y la sella en el store; e hidrata
2//! artefactos al FHS. Ver `docs/02-build-lab.md` y `docs/03-hydration.md`.
3
4use std::path::{Path, PathBuf};
5
6use hammer_core::{ArtifactHash, LinkMode, Phases, Recipe, Store};
7
8pub mod config;
9pub mod fetch;
10pub mod hydrate;
11pub mod sandbox;
12pub mod swm_bridge;
13
14pub use config::BuildConfig;
15pub use hydrate::{hydrate as run_hydrate, HydrateReport, HydratedFile};
16pub use sandbox::Sandbox;
17pub use swm_bridge::build_source_patch;
18
19/// Calcula el `ArtifactHash` de una receta, resolviendo recursivamente sus deps de build.
20/// Ver `docs/02-build-lab.md` §2 y §5.
21pub fn artifact_hash(recipe: &Recipe, _store: &Store) -> hammer_core::Result<ArtifactHash> {
22 let dep_hashes: Vec<ArtifactHash> = Vec::new();
23 for _dep in &recipe.deps.build {
24 // TODO(fase-0): cargar la receta de la dep y recursar.
25 }
26 let inputs = recipe.hash_inputs(&dep_hashes)?;
27 let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect();
28 Ok(ArtifactHash::of_inputs(&refs))
29}
30
31/// Compila una receta y devuelve el hash del artefacto sellado en el store.
32/// Si el hash ya existe en el store, devuelve sin recompilar (caché).
33pub fn build(
34 recipe: &Recipe,
35 cfg: &BuildConfig,
36 store: &Store,
37) -> hammer_core::Result<ArtifactHash> {
38 let h = artifact_hash(recipe, store)?;
39 if store.has(&h, &recipe.name) {
40 tracing::info!(hash = %h, name = %recipe.name, "caché: artefacto ya en el store");
41 return Ok(h);
42 }
43 sandbox::ensure_layout(&cfg.rootfs, &cfg.zig_dir)?;
44
45 tracing::info!(hash = %h, name = %recipe.name, "fetch");
46 let src_tree = fetch::fetch(recipe, &cfg.work_root)?;
47
48 if !recipe.source.patches.is_empty() {
49 tracing::info!(n = recipe.source.patches.len(), "aplicando patches");
50 fetch::apply_patches(recipe, &src_tree)?;
51 }
52
53 let out_dir = unique_out_dir(&cfg.work_root, &recipe.name, &h)?;
54 let mut env: Vec<(String, String)> = Vec::new();
55 if matches!(recipe.build.link, LinkMode::Static) {
56 // autotools/cmake leen LDFLAGS para el link final; -static fuerza el binario
57 // monolítico que pide nuestra estrategia de hidratación primaria.
58 env.push(("LDFLAGS".into(), "-static".into()));
59 }
60 if let Some(cache) = &cfg.cache_root {
61 std::fs::create_dir_all(cache.join("zig"))?;
62 }
63 let sb = Sandbox {
64 rootfs: cfg.rootfs.clone(),
65 zig_dir: cfg.zig_dir.clone(),
66 src_dir: src_tree.clone(),
67 out_dir: out_dir.clone(),
68 cache_dir: cfg.cache_root.clone(),
69 env,
70 };
71
72 let phases = resolve_phases(recipe, &src_tree)?;
73 if let Some(cmd) = &phases.configure {
74 tracing::info!(cmd, "phase: configure");
75 sb.run(cmd)?;
76 }
77 if let Some(cmd) = &phases.compile {
78 tracing::info!(cmd, "phase: compile");
79 sb.run(cmd)?;
80 } else {
81 return Err(hammer_core::Error::Recipe(
82 "receta sin fase 'compile' y heurística no encontró ninguna".into(),
83 ));
84 }
85 if let Some(cmd) = &phases.install {
86 tracing::info!(cmd, "phase: install");
87 sb.run(cmd)?;
88 } else {
89 return Err(hammer_core::Error::Recipe(
90 "receta sin fase 'install' y heurística no encontró ninguna".into(),
91 ));
92 }
93
94 let sealed = store.seal(&out_dir, &h, &recipe.name)?;
95 tracing::info!(path = %sealed.display(), "sealed");
96 Ok(h)
97}
98
99/// Sistemas de build que reconoce la heurística por inspección del árbol del source.
100/// Ver `docs/02-build-lab.md` §4.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102enum BuildSys {
103 /// `configure.ac` (o `.in`) sin `configure` generado: hay que correr `autoreconf` primero.
104 AutoconfRaw,
105 /// `configure` ya está en el tarball release: vamos directo a `./configure`.
106 AutoconfReady,
107 /// `CMakeLists.txt`.
108 CMake,
109 /// `meson.build`.
110 Meson,
111 /// `Makefile` plano (o `makefile`) sin sistema generador.
112 Make,
113 /// No detectamos nada: la receta debe traer overrides en `[build.phases]`.
114 Unknown,
115}
116
117fn detect_build_system(src: &Path) -> BuildSys {
118 let has = |p: &str| src.join(p).exists();
119 // El orden importa: cuando coexisten varios (un tarball release de autotools trae el
120 // `configure` regenerado y un `Makefile` huérfano de un build previo), preferimos el
121 // sistema "más alto" que sí sabe regenerar todo.
122 if has("configure.ac") || has("configure.in") {
123 if has("configure") {
124 return BuildSys::AutoconfReady;
125 }
126 return BuildSys::AutoconfRaw;
127 }
128 if has("configure") {
129 return BuildSys::AutoconfReady;
130 }
131 if has("CMakeLists.txt") {
132 return BuildSys::CMake;
133 }
134 if has("meson.build") {
135 return BuildSys::Meson;
136 }
137 if has("Makefile") || has("makefile") || has("GNUmakefile") {
138 return BuildSys::Make;
139 }
140 BuildSys::Unknown
141}
142
143/// Devuelve los comandos a ejecutar para cada fase: overrides explícitos en la receta
144/// ganan; el resto lo deriva la heurística según el sistema de build detectado.
145fn resolve_phases(recipe: &Recipe, src: &Path) -> hammer_core::Result<Phases> {
146 let mut out = recipe.build.phases.clone();
147 let bs = detect_build_system(src);
148 let flags = recipe.build.flags.join(" ");
149 let flags_suffix = if flags.is_empty() { String::new() } else { format!(" {flags}") };
150
151 if out.configure.is_none() {
152 out.configure = match bs {
153 BuildSys::AutoconfRaw => Some(format!(
154 "autoreconf -fi && ./configure --prefix=/usr{flags_suffix}"
155 )),
156 BuildSys::AutoconfReady => Some(format!("./configure --prefix=/usr{flags_suffix}")),
157 BuildSys::CMake => Some(format!(
158 "cmake -S . -B _build -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release{flags_suffix}"
159 )),
160 BuildSys::Meson => Some(format!("meson setup _build --prefix=/usr{flags_suffix}")),
161 BuildSys::Make | BuildSys::Unknown => None,
162 };
163 }
164 if out.compile.is_none() {
165 out.compile = match bs {
166 BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => {
167 Some(r#"make -j"$(nproc)""#.to_string())
168 }
169 BuildSys::CMake => Some(r#"cmake --build _build -j "$(nproc)""#.to_string()),
170 BuildSys::Meson => Some("meson compile -C _build".to_string()),
171 BuildSys::Unknown => None,
172 };
173 }
174 if out.install.is_none() {
175 out.install = match bs {
176 BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => {
177 Some("make install DESTDIR=/out PREFIX=/usr".to_string())
178 }
179 BuildSys::CMake => Some("DESTDIR=/out cmake --install _build".to_string()),
180 BuildSys::Meson => Some("DESTDIR=/out meson install -C _build --no-rebuild".to_string()),
181 BuildSys::Unknown => None,
182 };
183 }
184 Ok(out)
185}
186
187#[cfg(test)]
188mod heuristic_tests {
189 use super::*;
190
191 fn touch(dir: &Path, name: &str) {
192 std::fs::write(dir.join(name), b"").unwrap();
193 }
194
195 fn recipe(flags: &[&str]) -> Recipe {
196 let mut r = Recipe::from_toml(
197 r#"
198name = "x"
199version = "0"
200[source]
201repo = "git://x"
202commit = "deadbeef"
203[build]
204"#,
205 )
206 .unwrap();
207 r.build.flags = flags.iter().map(|s| (*s).to_string()).collect();
208 r
209 }
210
211 #[test]
212 fn detect_autoconf_raw_when_only_configure_ac() {
213 let d = tempfile::tempdir().unwrap();
214 touch(d.path(), "configure.ac");
215 assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfRaw);
216 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
217 assert!(p.configure.as_deref().unwrap().starts_with("autoreconf -fi &&"));
218 assert!(p.install.as_deref().unwrap().contains("DESTDIR=/out"));
219 }
220
221 #[test]
222 fn detect_autoconf_ready_when_configure_exists() {
223 let d = tempfile::tempdir().unwrap();
224 touch(d.path(), "configure.ac");
225 touch(d.path(), "configure");
226 assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfReady);
227 let p = resolve_phases(&recipe(&["--enable-foo"]), d.path()).unwrap();
228 let c = p.configure.unwrap();
229 assert!(c.starts_with("./configure --prefix=/usr"), "{c}");
230 assert!(c.ends_with("--enable-foo"), "{c}");
231 }
232
233 #[test]
234 fn detect_cmake() {
235 let d = tempfile::tempdir().unwrap();
236 touch(d.path(), "CMakeLists.txt");
237 assert_eq!(detect_build_system(d.path()), BuildSys::CMake);
238 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
239 assert!(p.configure.unwrap().contains("-DCMAKE_INSTALL_PREFIX=/usr"));
240 assert!(p.compile.unwrap().contains("cmake --build _build"));
241 assert!(p.install.unwrap().contains("DESTDIR=/out"));
242 }
243
244 #[test]
245 fn detect_meson() {
246 let d = tempfile::tempdir().unwrap();
247 touch(d.path(), "meson.build");
248 assert_eq!(detect_build_system(d.path()), BuildSys::Meson);
249 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
250 assert!(p.configure.unwrap().contains("meson setup _build"));
251 assert!(p.install.unwrap().contains("meson install"));
252 }
253
254 #[test]
255 fn detect_make() {
256 let d = tempfile::tempdir().unwrap();
257 touch(d.path(), "Makefile");
258 assert_eq!(detect_build_system(d.path()), BuildSys::Make);
259 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
260 assert!(p.configure.is_none(), "make plano no necesita configure");
261 assert!(p.compile.unwrap().starts_with("make"));
262 }
263
264 #[test]
265 fn unknown_returns_no_phases() {
266 let d = tempfile::tempdir().unwrap();
267 // sólo un README, ningún archivo característico.
268 std::fs::write(d.path().join("README"), b"hi").unwrap();
269 assert_eq!(detect_build_system(d.path()), BuildSys::Unknown);
270 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
271 assert!(p.compile.is_none());
272 assert!(p.install.is_none());
273 }
274
275 #[test]
276 fn recipe_override_wins_over_heuristic() {
277 let d = tempfile::tempdir().unwrap();
278 touch(d.path(), "Makefile");
279 let mut r = recipe(&[]);
280 r.build.phases.compile = Some("zig cc -static -o foo foo.c".to_string());
281 r.build.phases.install = Some("cp foo /out/bin/foo".to_string());
282 let p = resolve_phases(&r, d.path()).unwrap();
283 assert_eq!(p.compile.unwrap(), "zig cc -static -o foo foo.c");
284 assert_eq!(p.install.unwrap(), "cp foo /out/bin/foo");
285 }
286}
287
288
289fn unique_out_dir(
290 work_root: &Path,
291 name: &str,
292 h: &ArtifactHash,
293) -> hammer_core::Result<PathBuf> {
294 let dir = work_root
295 .join("out")
296 .join(h.store_dir_name(name));
297 if dir.exists() {
298 std::fs::remove_dir_all(&dir)?;
299 }
300 std::fs::create_dir_all(&dir)?;
301 Ok(dir)
302}
303
304/// Proyecta un artefacto del store al FHS (real o de overlay). Resuelve el directorio del
305/// artefacto a partir del hash y delega en [`hydrate::hydrate`].
306pub fn hydrate(
307 h: &ArtifactHash,
308 store: &Store,
309 target_fhs: &Path,
310 mode: LinkMode,
311) -> hammer_core::Result<HydrateReport> {
312 let artifact_dir = store.find_by_hash(h.as_str())?;
313 hydrate::hydrate(&artifact_dir, target_fhs, mode)
314}