diff --git a/crates/hammer-build/src/config.rs b/crates/hammer-build/src/config.rs index 6cda2eb6..918c8466 100644 --- a/crates/hammer-build/src/config.rs +++ b/crates/hammer-build/src/config.rs @@ -21,27 +21,88 @@ pub struct BuildConfig { pub cache_root: Option, } +/// Nombre del directorio del lab, buscado hacia arriba como hace git con `.git`. +const LAB_DIR: &str = ".dev-fs"; + +/// Busca el lab (`.dev-fs`) desde `start` hacia arriba. Pura y testeable: no mira env ni CWD. +fn find_lab_upward(start: &Path) -> Option { + start.ancestors().map(|d| d.join(LAB_DIR)).find(|c| c.is_dir()) +} + +/// Resuelve dónde vive el lab SIN atarlo a dónde vive el store. +/// +/// Orden, de más explícito a más adivinado: +/// 1. `HAMMER_LAB` — el operador manda. +/// 2. Hermano del store (`/../.dev-fs`) **si existe**. Preserva exactamente el +/// comportamiento histórico en la topología normal (lab y store en la raíz del proyecto). +/// 3. Hacia arriba desde el CWD. Ésta es la que desacopla: cubre el caso en que el store fue +/// movido a otro filesystem (volumen persistente) y el lab se quedó en el proyecto. +/// 4. `None` ⇒ el llamador cae al default histórico, para que los mensajes de error sigan +/// apuntando a un lugar previsible en vez de a la nada. +fn resolve_lab_dir(store_root: &Path) -> Option { + if let Some(v) = std::env::var_os("HAMMER_LAB") { + return Some(PathBuf::from(v)); + } + if let Some(parent) = store_root.parent() { + let sibling = parent.join(LAB_DIR); + if sibling.is_dir() { + return Some(sibling); + } + } + find_lab_upward(&std::env::current_dir().ok()?) +} + impl BuildConfig { /// Defaults relativos al store: `.dev-fs` y `work/` como hermanos del store. /// `store_root = /…/store` → rootfs = `/…/.dev-fs/alpine`, zig = `/…/.dev-fs/tools/zig`, /// work = `/…/work`, cache = `/…/.dev-fs/cache`. + /// + /// PURA a propósito (no mira env ni CWD): los tests montan configs herméticas con esto. Quien + /// quiera la resolución real del entorno usa [`Self::from_env_or_defaults`]. pub fn defaults_for_store(store_root: &Path) -> Self { let project_root = store_root .parent() .unwrap_or_else(|| Path::new(".")) .to_path_buf(); + Self::defaults_for_store_with_lab(store_root, &project_root.join(LAB_DIR)) + } + + /// Igual que [`Self::defaults_for_store`] pero con el lab (`.dev-fs`) puesto a mano. + /// + /// POR QUÉ EXISTE (2026-07-22): atar el lab al PADRE DEL STORE resultó ser una suposición + /// oculta y cara. Al anclar el store de un worker a un volumen persistente (symlink a + /// `/mnt/cosecha/store`), el lab pasó a buscarse en `/mnt/cosecha/.dev-fs` — que no existe — y + /// TODA receta con `zig_version` murió con "no encuentro el ejecutable zig en …", un mensaje + /// que apunta al lugar equivocado (el zig estaba, y estaba bien). Dos campañas leyeron eso como + /// deuda de la cascada GUI. Son dos decisiones independientes: dónde se GUARDA lo sellado + /// (almacenamiento) y dónde vive el toolchain de desarrollo (entorno). + /// + /// `work_root` SÍ sigue al store, y es deliberado: el `seal` final es un rename, que sólo es + /// atómico dentro del mismo filesystem. Ése es un acoplamiento real, no una suposición. + pub fn defaults_for_store_with_lab(store_root: &Path, lab: &Path) -> Self { + let store_parent = store_root + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); Self { - rootfs: project_root.join(".dev-fs/alpine"), - zig_dir: project_root.join(".dev-fs/tools/zig"), - work_root: project_root.join("work"), - cache_root: Some(project_root.join(".dev-fs/cache")), + rootfs: lab.join("alpine"), + zig_dir: lab.join("tools/zig"), + work_root: store_parent.join("work"), + cache_root: Some(lab.join("cache")), } } - /// Lee overrides desde entorno (`HAMMER_ROOTFS`, `HAMMER_ZIG`, `HAMMER_WORK`, + /// Lee overrides desde entorno (`HAMMER_LAB`, `HAMMER_ROOTFS`, `HAMMER_ZIG`, `HAMMER_WORK`, /// `HAMMER_CACHE`). `HAMMER_CACHE=""` (vacío) desactiva la caché. + /// + /// Es el constructor que HUELE EL ENTORNO (env + CWD); `defaults_for_store` se mantiene pura. + /// `HAMMER_LAB` mueve el lab entero de una; los `HAMMER_ROOTFS`/`HAMMER_ZIG`/`HAMMER_CACHE` + /// siguen pisando pieza por pieza y ganan sobre él. pub fn from_env_or_defaults(store_root: &Path) -> Self { - let mut cfg = Self::defaults_for_store(store_root); + let mut cfg = match resolve_lab_dir(store_root) { + Some(lab) => Self::defaults_for_store_with_lab(store_root, &lab), + None => Self::defaults_for_store(store_root), + }; if let Ok(v) = std::env::var("HAMMER_ROOTFS") { cfg.rootfs = PathBuf::from(v); } @@ -57,3 +118,64 @@ impl BuildConfig { cfg } } + +#[cfg(test)] +mod tests { + use super::*; + + /// La topología normal (lab y store hermanos en la raíz) no se mueve. + #[test] + fn lab_hermano_del_store_sigue_igual() { + let cfg = BuildConfig::defaults_for_store(Path::new("/proj/store")); + assert_eq!(cfg.rootfs, Path::new("/proj/.dev-fs/alpine")); + assert_eq!(cfg.zig_dir, Path::new("/proj/.dev-fs/tools/zig")); + assert_eq!(cfg.cache_root.unwrap(), Path::new("/proj/.dev-fs/cache")); + assert_eq!(cfg.work_root, Path::new("/proj/work")); + } + + /// LA REGRESIÓN: store mudado a un volumen, lab quieto en el proyecto. El lab debe seguir al + /// proyecto y NO al store; `work` debe seguir al STORE (mismo filesystem ⇒ seal atómico). + #[test] + fn lab_y_store_pueden_vivir_en_filesystems_distintos() { + let cfg = BuildConfig::defaults_for_store_with_lab( + Path::new("/mnt/cosecha/store"), + Path::new("/opt/hammer/.dev-fs"), + ); + assert_eq!(cfg.zig_dir, Path::new("/opt/hammer/.dev-fs/tools/zig")); + assert_eq!(cfg.rootfs, Path::new("/opt/hammer/.dev-fs/alpine")); + assert_eq!(cfg.cache_root.unwrap(), Path::new("/opt/hammer/.dev-fs/cache")); + assert_eq!( + cfg.work_root, + Path::new("/mnt/cosecha/work"), + "work DEBE seguir al store: el seal es un rename y sólo es atómico en el mismo FS" + ); + } + + #[test] + fn find_lab_upward_encuentra_desde_un_subdirectorio() { + let d = tempfile::tempdir().unwrap(); + let raiz = d.path().join("proj"); + std::fs::create_dir_all(raiz.join(LAB_DIR)).unwrap(); + let hondo = raiz.join("a/b/c"); + std::fs::create_dir_all(&hondo).unwrap(); + assert_eq!(find_lab_upward(&hondo), Some(raiz.join(LAB_DIR))); + } + + #[test] + fn find_lab_upward_no_inventa_lo_que_no_existe() { + let d = tempfile::tempdir().unwrap(); + let hondo = d.path().join("sin/lab/alguno"); + std::fs::create_dir_all(&hondo).unwrap(); + assert_eq!(find_lab_upward(&hondo), None); + } + + /// Un fichero llamado `.dev-fs` no es un lab: la búsqueda exige directorio. + #[test] + fn find_lab_upward_ignora_un_fichero_homonimo() { + let d = tempfile::tempdir().unwrap(); + let raiz = d.path().join("proj"); + std::fs::create_dir_all(&raiz).unwrap(); + std::fs::write(raiz.join(LAB_DIR), b"no soy un lab").unwrap(); + assert_eq!(find_lab_upward(&raiz), None); + } +}