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/swm_bridge.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
Fase 4 — formato .swm: verify, apply (config_edit/file_drop/source_patch) y export
1//! Puente entre `hammer_core::Mutation::SourcePatch` y el laboratorio.
2//!
3//! Convierte la mutación serializada en una `Recipe` efímera + un archivo de patch
4//! temporal, y delega en `build` para sellar el artefacto en el store. Luego se hidrata.
5//!
6//! Este módulo vive en `hammer-build` (no en `hammer-core`) porque depende del store y del
7//! sandbox. `hammer-core` debe permanecer puro (sin red ni mounts) para que el watcher y los
8//! tests no arrastren toda la cadena del lab.
9
10use std::path::{Path, PathBuf};
11
12use hammer_core::{
13 swm::{Mutation, SwmBuild},
14 ArtifactHash, Compiler, LinkMode, Recipe, Store,
15};
16
17use crate::{build, BuildConfig};
18
19/// Construye una `SourcePatch` y devuelve el hash sellado en el store. El llamador hidrata
20/// luego (típicamente al overlay activo).
21///
22/// `scratch_root` sirve para escribir el patch inline a disco — usamos `cfg.work_root` por
23/// defecto si el caller no quiere gestionarlo a mano.
24pub fn build_source_patch(
25 mutation: &Mutation,
26 cfg: &BuildConfig,
27 store: &Store,
28 scratch_root: Option<&Path>,
29) -> hammer_core::Result<ArtifactHash> {
30 let (repo, commit, patch, patch_url, build_cfg, target_bin, expected) = match mutation {
31 Mutation::SourcePatch {
32 repo,
33 commit,
34 patch,
35 patch_url,
36 build,
37 target_bin,
38 expected_hash,
39 } => (repo, commit, patch, patch_url, build, target_bin, expected_hash),
40 _ => {
41 return Err(hammer_core::Error::Recipe(
42 "build_source_patch: la mutación no es 'source_patch'".into(),
43 ));
44 }
45 };
46
47 if patch_url.is_some() {
48 return Err(hammer_core::Error::Recipe(
49 "patch_url remoto no soportado todavía: usa 'patch' inline".into(),
50 ));
51 }
52
53 let scratch = scratch_root.unwrap_or(&cfg.work_root).to_path_buf();
54 let recipe_dir = scratch.join("swm-recipes");
55 std::fs::create_dir_all(&recipe_dir)?;
56
57 // Si trae patch inline, lo materializamos a disco bajo un nombre derivado del commit
58 // (estable: si el .swm se reaplica, reusamos el mismo archivo y el hash de la receta no
59 // depende de aleatoriedad).
60 let mut patches: Vec<String> = Vec::new();
61 if let Some(text) = patch {
62 let patch_path = recipe_dir.join(format!("{commit}.patch"));
63 std::fs::write(&patch_path, text.as_bytes())?;
64 patches.push(patch_path.to_string_lossy().into_owned());
65 }
66
67 let name = derive_name(target_bin);
68 let recipe = synthesize_recipe(
69 &name,
70 commit,
71 repo,
72 build_cfg,
73 patches,
74 &recipe_dir,
75 )?;
76
77 let hash = build(&recipe, cfg, store)?;
78 if let Some(want) = expected {
79 let want_hex = want.strip_prefix("b3:").unwrap_or(want);
80 let got_hex = hash
81 .as_str()
82 .strip_prefix("b3:")
83 .expect("of_inputs siempre devuelve 'b3:'");
84 if want_hex != got_hex {
85 return Err(hammer_core::Error::Recipe(format!(
86 "expected_hash no coincide: declarado=b3:{want_hex}, obtenido={hash}"
87 )));
88 }
89 }
90 Ok(hash)
91}
92
93/// Nombre legible para el directorio del store. `/usr/bin/grep` → `grep`. Si el path no
94/// tiene basename (raro), usamos `swm-bin`. El nombre afecta SÓLO al sufijo legible
95/// (`<hash>-<name>`), no al hash; cambiarlo no rompe la caché del store.
96fn derive_name(target_bin: &str) -> String {
97 Path::new(target_bin)
98 .file_name()
99 .and_then(|s| s.to_str())
100 .unwrap_or("swm-bin")
101 .to_string()
102}
103
104fn synthesize_recipe(
105 name: &str,
106 commit: &str,
107 repo: &str,
108 build_cfg: &SwmBuild,
109 patches: Vec<String>,
110 base_dir: &Path,
111) -> hammer_core::Result<Recipe> {
112 let compiler = parse_compiler(&build_cfg.compiler)?;
113 let link = parse_link(&build_cfg.link)?;
114 // Construimos la receta vía TOML para reusar las defaults y el validador de `Source`.
115 let toml_text = format!(
116 r#"
117name = "{name}"
118version = "swm-{commit_short}"
119
120[source]
121repo = "{repo}"
122commit = "{commit}"
123
124[build]
125compiler = "{compiler}"
126target = "{target}"
127link = "{link}"
128flags = []
129"#,
130 name = name,
131 commit_short = &commit[..commit.len().min(12)],
132 repo = repo,
133 commit = commit,
134 compiler = compiler.as_str(),
135 target = build_cfg.target,
136 link = link.as_str(),
137 );
138 let mut recipe = Recipe::from_toml(&toml_text)?;
139 recipe.build.flags = build_cfg.flags.clone();
140 // Resolución de patches relativos al recipe_dir donde acabamos de escribir los inline.
141 // Aceptamos rutas absolutas tal cual: las hemos generado nosotros.
142 recipe.source.patches = patches
143 .into_iter()
144 .map(|p| {
145 let path = Path::new(&p);
146 if path.is_absolute() {
147 p
148 } else {
149 base_dir.join(&p).to_string_lossy().into_owned()
150 }
151 })
152 .collect();
153 recipe.base_dir = PathBuf::from("/"); // patches absolutos: base_dir es irrelevante
154 Ok(recipe)
155}
156
157fn parse_compiler(s: &str) -> hammer_core::Result<Compiler> {
158 match s {
159 "zig-cc" => Ok(Compiler::ZigCc),
160 "clang" => Ok(Compiler::Clang),
161 "gcc" => Ok(Compiler::Gcc),
162 other => Err(hammer_core::Error::Recipe(format!(
163 "compiler desconocido: '{other}' (esperado zig-cc/clang/gcc)"
164 ))),
165 }
166}
167
168fn parse_link(s: &str) -> hammer_core::Result<LinkMode> {
169 match s {
170 "static" => Ok(LinkMode::Static),
171 "dynamic" => Ok(LinkMode::Dynamic),
172 other => Err(hammer_core::Error::Recipe(format!(
173 "link desconocido: '{other}' (esperado static/dynamic)"
174 ))),
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 fn fake_swm_build() -> SwmBuild {
183 SwmBuild {
184 compiler: "zig-cc".into(),
185 target: "x86_64-linux-musl".into(),
186 link: "static".into(),
187 flags: vec!["--enable-foo".into()],
188 }
189 }
190
191 #[test]
192 fn derive_name_from_target() {
193 assert_eq!(derive_name("/usr/bin/grep"), "grep");
194 assert_eq!(derive_name("/bin/ls"), "ls");
195 assert_eq!(derive_name("/"), "swm-bin");
196 }
197
198 #[test]
199 fn synthesize_recipe_basic() {
200 let d = tempfile::tempdir().unwrap();
201 let r = synthesize_recipe(
202 "grep",
203 "a1b2c3d4e5f6a7b8c9",
204 "git://example/grep.git",
205 &fake_swm_build(),
206 vec![],
207 d.path(),
208 )
209 .unwrap();
210 assert_eq!(r.name, "grep");
211 assert!(r.version.starts_with("swm-a1b2c3d4"));
212 assert_eq!(r.build.flags, vec!["--enable-foo"]);
213 match r.source.kind().unwrap() {
214 hammer_core::SourceKind::Git { repo, commit } => {
215 assert_eq!(repo, "git://example/grep.git");
216 assert_eq!(commit, "a1b2c3d4e5f6a7b8c9");
217 }
218 _ => panic!("modo git esperado"),
219 }
220 }
221
222 #[test]
223 fn parse_compiler_and_link() {
224 assert_eq!(parse_compiler("zig-cc").unwrap(), Compiler::ZigCc);
225 assert!(parse_compiler("xlc").is_err());
226 assert_eq!(parse_link("static").unwrap(), LinkMode::Static);
227 assert!(parse_link("partial").is_err());
228 }
229
230 #[test]
231 fn rejects_non_source_patch_mutation() {
232 let m = Mutation::ConfigEdit {
233 file: "/etc/x".into(),
234 inline_diff: "- a\n+ b\n".into(),
235 };
236 let d = tempfile::tempdir().unwrap();
237 let store = Store::open(d.path().join("store")).unwrap();
238 let cfg = BuildConfig::defaults_for_store(store.root());
239 let err = build_source_patch(&m, &cfg, &store, None).unwrap_err().to_string();
240 assert!(err.contains("source_patch"), "{err}");
241 }
242
243 #[test]
244 fn rejects_patch_url() {
245 let m = Mutation::SourcePatch {
246 repo: "git://x".into(),
247 commit: "abc".into(),
248 patch: None,
249 patch_url: Some("https://example/foo.patch".into()),
250 build: fake_swm_build(),
251 target_bin: "/bin/x".into(),
252 expected_hash: None,
253 };
254 let d = tempfile::tempdir().unwrap();
255 let store = Store::open(d.path().join("store")).unwrap();
256 let cfg = BuildConfig::defaults_for_store(store.root());
257 let err = build_source_patch(&m, &cfg, &store, None).unwrap_err().to_string();
258 assert!(err.contains("patch_url"), "{err}");
259 }
260}