diff --git a/Cargo.lock b/Cargo.lock index 327194cd..5feba38d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -524,6 +524,7 @@ dependencies = [ "anyhow", "hammer-core", "hex", + "serde_json", "sha2", "tempfile", "thiserror", diff --git a/crates/hammer-build/Cargo.toml b/crates/hammer-build/Cargo.toml index 268d3607..d64e2c76 100644 --- a/crates/hammer-build/Cargo.toml +++ b/crates/hammer-build/Cargo.toml @@ -14,6 +14,7 @@ thiserror.workspace = true tracing.workspace = true sha2.workspace = true hex.workspace = true +serde_json.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/hammer-build/src/lib.rs b/crates/hammer-build/src/lib.rs index d2f3d02c..3682913c 100644 --- a/crates/hammer-build/src/lib.rs +++ b/crates/hammer-build/src/lib.rs @@ -170,6 +170,12 @@ pub fn build( fetch::apply_patches(recipe, &src_tree)?; } + // Receta efectiva para resolver las FASES: puede ganar un `-p ` si la fuente es un + // workspace Cargo virtual (ver inject_cargo_package_selector). El hash `h` ya está fijado desde + // los flags ORIGINALES de la receta; el `-p` es resolución determinista del lab (misma fuente → + // mismo paquete) ⇒ no afecta la reproducibilidad ni el sidecar (que guarda la receta original). + let mut eff_recipe = recipe.clone(); + // Recetas Cargo: vendoreamos las deps (red permitida en el fetch) para que el build sea // hermético `--offline` dentro del sandbox. Tras los patches, por si tocaron Cargo.toml/lock. if matches!(detect_build_system(&src_tree), BuildSys::Cargo) { @@ -180,6 +186,8 @@ pub fn build( // duplicar la tabla). Esto desbloquea genéricamente los imports Rust (Etapa G) sin un patch // por receta. Ver memoria 'cargo-recipe crate suelto'. ensure_cargo_workspace_isolation(&src_tree)?; + // Extractor de workspaces (Etapa G): si el root es virtual, resuelve `-p ` del bin. + inject_cargo_package_selector(&mut eff_recipe, &src_tree); tracing::info!("cargo: vendoreando deps para build offline"); fetch::vendor_cargo_deps(&src_tree)?; } @@ -222,7 +230,7 @@ pub fn build( env, }; - let phases = resolve_phases(recipe, &src_tree)?; + let phases = resolve_phases(&eff_recipe, &src_tree)?; if let Some(cmd) = &phases.configure { tracing::info!(cmd, "phase: configure"); sb.run(cmd)?; @@ -280,6 +288,84 @@ enum BuildSys { Unknown, } +/// ¿El `Cargo.toml` raíz es un MANIFIESTO VIRTUAL? (declara `[workspace]` pero NO `[package]`). +/// Es el patrón del ecosistema CLI Rust moderno (sd, fd, …): el root sólo agrupa miembros y el +/// binario vive en un sub-paquete. En ese caso `cargo … --bin X` solo falla: hace falta `-p `. +/// Un crate suelto (con `[package]`, aunque `ensure_cargo_workspace_isolation` le agregue +/// `[workspace]`) NO es virtual ⇒ no se toca (ripgrep y el corpus quedan idénticos). +fn cargo_root_is_virtual(src: &Path) -> bool { + let Ok(text) = std::fs::read_to_string(src.join("Cargo.toml")) else { + return false; + }; + let mut has_ws = false; + let mut has_pkg = false; + for l in text.lines() { + let t = l.trim_start(); + if t.starts_with("[workspace]") || t.starts_with("[workspace.") { + has_ws = true; + } + if t.starts_with("[package]") { + has_pkg = true; + } + } + has_ws && !has_pkg +} + +/// Lee los MEMBERS del workspace (vía `cargo metadata`, la fuente autoritativa: expande globs, +/// `[[bin]]`, bins por convención `src/bin/*.rs`, y workspaces anidados — todo lo que un parseo a +/// mano del TOML erraría) y devuelve el NOMBRE del paquete que expone un bin llamado `bin`. Es el +/// "extractor de workspaces" de la Etapa G: convierte un workspace virtual en un `-p ` exacto. +fn resolve_cargo_bin_package(src: &Path, bin: &str) -> Option { + let out = std::process::Command::new("cargo") + .args(["metadata", "--no-deps", "--format-version", "1"]) + .current_dir(src) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let meta: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?; + for pkg in meta.get("packages")?.as_array()? { + let pkg_name = pkg.get("name")?.as_str()?; + for tgt in pkg.get("targets")?.as_array()? { + let is_bin = tgt + .get("kind") + .and_then(|k| k.as_array()) + .map(|ks| ks.iter().any(|x| x.as_str() == Some("bin"))) + .unwrap_or(false); + if is_bin && tgt.get("name").and_then(|n| n.as_str()) == Some(bin) { + return Some(pkg_name.to_string()); + } + } + } + None +} + +/// Si la fuente es un workspace VIRTUAL y los flags piden `--bin X` sin `-p`, resuelve el paquete +/// real que expone `X` y antepone `-p `. Deja los flags intactos en cualquier otro caso +/// (crate suelto, ya con `-p`, o bin irresoluble). Determinista (misma fuente → mismo paquete) ⇒ +/// reproducible. Sin esto, casi todo el CLI Rust moderno (workspaces virtuales) exigía `-p` a mano. +fn inject_cargo_package_selector(recipe: &mut Recipe, src: &Path) { + let flags = &recipe.build.flags; + if flags.iter().any(|f| f == "-p" || f == "--package") { + return; // el autor ya eligió el paquete + } + if !cargo_root_is_virtual(src) { + return; // crate suelto: `--bin` solo alcanza + } + // bin = el argumento que sigue a `--bin`. + let Some(bin) = flags.iter().position(|f| f == "--bin").and_then(|i| flags.get(i + 1)).cloned() + else { + return; + }; + if let Some(pkg) = resolve_cargo_bin_package(src, &bin) { + tracing::info!(%bin, %pkg, "cargo: workspace virtual ⇒ -p resuelto automáticamente"); + let mut new_flags = vec!["-p".to_string(), pkg]; + new_flags.extend(recipe.build.flags.iter().cloned()); + recipe.build.flags = new_flags; + } +} + /// Garantiza que el `Cargo.toml` raíz de la fuente declare un `[workspace]` propio, para que cargo /// NO la considere parte del workspace del repo hammer (que la contiene en `work/sources/`). Append /// idempotente: si ya hay `[workspace]` (crate suelto que es su propio workspace, o una receta que