//! H4b — el gate de **compatibilidad de configs** sobre la receta real (SDD 15 §H4). //! //! Sube a takana el modelo de slots que `wawa-memo/src/compat.rs` demostró como //! prototipo (host/std, 52 tests verdes): una config/paquete declara qué //! superficies del sistema **reclama** (`Slots::claims`) y **requiere** //! (`Slots::requires`), ambas por hash. Antes de tocar el sistema, `install` //! evalúa la config entrante contra el **estado instalado** //! ([`crate::InstalledDb::system_state`]) y decide: //! //! - **Compatible** — requisitos resueltos y reclamos disjuntos (o coincidentes): //! se puede instalar sin fricción. //! - **Colisión** — reclama una superficie ya ocupada por OTRO contenido: es una //! **elección** del usuario (el caso "ya cambié el logo y bajo otra config que //! también lo cambia"), no un error. `install` la respeta salvo `--force-slots`. //! - **Incompatible** — un requisito no resuelve contra el estado: **rechazo //! duro** (el caso "modifiqué el protocolo de Wayland y bajo algo que depende //! del Wayland stock"). Es el análogo de sistema a una referencia colgante. //! //! El chequeo es **local y reproducible** — el mismo "verificar, no confiar" de //! SDD 09, ahora sobre la topología de superficies. "Compatible" (este módulo) es //! el eje NUEVO; "completa" y "segura" ya viven en el stack (cierre transitivo / //! reproducir el resultado) — ver el prototipo `wawa-memo` para la composición. use std::collections::BTreeSet; use crate::recipe::Slots; use crate::swm::{Mutation, Swm}; /// El estado instalado como `slot → hash`. Alias de lo que produce /// [`crate::InstalledDb::system_state`]. pub type Estado = std::collections::BTreeMap; /// El veredicto de evaluar unos [`Slots`] contra un [`Estado`]. Tres resultados /// **mecánicamente distintos**: la incompatibilidad domina a la colisión. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Veredicto { /// Requisitos resueltos y reclamos disjuntos (o coincidentes en hash): instalable. Compatible, /// Un slot que la config reclama ya está ocupado por **otro** contenido. NO es /// error: es una **elección** (caso *logo*). Colision { slot: String, /// El hash que el slot tiene ahora en el estado. instalado: String, /// El hash que la config quiere poner. entrante: String, }, /// Un requisito **no resuelve** contra el estado (caso *wayland*): rechazo duro. /// `presente = None` si el estado ni ofrece el slot; `Some(otro)` si otra versión. Incompatible { slot: String, /// El hash que la config exige para ese slot. requerido: String, /// Lo que el estado ofrece para ese slot (o `None`). presente: Option, }, } impl Veredicto { /// `true` sólo si es [`Veredicto::Compatible`]. pub fn es_compatible(&self) -> bool { matches!(self, Veredicto::Compatible) } } /// Evalúa unos `slots` contra el `estado` instalado. Primero **requisitos** /// (incompatibilidad dura), luego **reclamos** (colisión = elección). Devuelve la /// **primera** objeción en orden determinista (`BTreeMap`), o /// [`Veredicto::Compatible`]. Un reclamo que coincide en hash es idempotente. pub fn evaluar(estado: &Estado, slots: &Slots) -> Veredicto { // 1. Requisitos: cada superficie requerida debe estar presente al hash exacto // contra el que la config fue construida. Es la referencia colgante de sistema. for (slot, requerido) in &slots.requires { match estado.get(slot) { Some(h) if h == requerido => {} otro => { return Veredicto::Incompatible { slot: slot.clone(), requerido: requerido.clone(), presente: otro.cloned(), }; } } } // 2. Reclamos: un slot ya ocupado por otro contenido es colisión (elección). for (slot, entrante) in &slots.claims { if let Some(instalado) = estado.get(slot) { if instalado != entrante { return Veredicto::Colision { slot: slot.clone(), instalado: instalado.clone(), entrante: entrante.clone(), }; } } } Veredicto::Compatible } // ───────────────────────────────────────────────────────────────────────── // H4c — superficies OBSERVADAS: la vía "no prometas, observá" // ───────────────────────────────────────────────────────────────────────── // // H4b confía en que el AUTOR declare `slots` a mano. La misma subida que hizo H1 // (de "prometer" a "verificar") aplica acá: la superficie más común y observable // de un paquete es **el conjunto de paths que escribe**, y esos paths ya están // DECLARADOS en el `.swm` (`target_bin` de cada source_patch, `path` de cada // file_drop) — se conocen ANTES de hidratar, así que el gate puede abortar sin // tocar nada. El "slot" es el path; la señal de colisión es que OTRO paquete // instalado ya lo posea. No hace falta que el autor prometa nada: se observa. /// Los paths ABSOLUTOS que un paquete **escribirá**, leídos de su `.swm`: el /// `target_bin` de cada `source_patch` y el `path` de cada `file_drop`. Es la /// superficie observada de H4c — conocida sin construir. pub fn output_paths(swm: &Swm) -> BTreeSet { let mut paths = BTreeSet::new(); for m in &swm.mutations { match m { Mutation::SourcePatch { target_bin, .. } => { paths.insert(target_bin.clone()); } Mutation::FileDrop { path, .. } => { paths.insert(path.clone()); } // config_edit MODIFICA un fichero ajeno (no lo crea/posee); init_rule escribe en el // dir de reglas del init. Ninguno "reclama" una superficie propia — se omiten. Mutation::ConfigEdit { .. } | Mutation::InitRule { .. } => {} } } paths } /// Una colisión de path observada: `entrante` escribirá un `path` que YA posee otro /// paquete instalado (`propietario`). Sobreescribir el fichero de otro paquete es una /// **elección** (el caso *logo* a nivel de fichero), no un error. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ColisionPath { pub path: String, /// El paquete instalado que ya posee ese path. pub propietario: String, } /// Colisiones de path del `entrante` (por nombre + los paths que escribirá) contra el estado /// instalado. Reinstalar el MISMO paquete sobre sus propios paths NO colisiona (es upgrade); /// sólo pisar un fichero de OTRO paquete cuenta. Reusa lo que `InstalledDb` ya registra — no /// necesita que la receta declare nada (H4c: observar, no prometer). pub fn path_collisions( db: &crate::InstalledDb, entrante_name: &str, entrante_paths: &BTreeSet, ) -> Vec { let mut cols = Vec::new(); for p in entrante_paths { if let Some(dueno) = db.owner_of(p) { if dueno != entrante_name { cols.push(ColisionPath { path: p.clone(), propietario: dueno.to_string(), }); } } } cols } // ───────────────────────────────────────────────────────────────────────── // H4e — `requires` OBSERVADOS: la versión contra la que el paquete se construyó // ───────────────────────────────────────────────────────────────────────── // // H4c observó lo que un paquete *escribe*; falta observar de qué *depende a una // versión* (el caso wayland automático). La fuente observable: un paquete se // construyó contra la versión de sus deps que hay EN EL REPO. Si el usuario tiene // esa dep instalada a OTRO hash, la diverguió ⇒ el paquete no encaja. No hace // falta que el autor prometa `requires`: se lee del cierre (deps del `.swm` + // `expected_hash` de cada dep en el índice). Es el análogo de sistema a un // requisito que no resuelve — el mismo rechazo duro de H4b, ahora derivado. /// [H4e] Los `requires` OBSERVADOS de un paquete: por cada dep de **runtime** /// declarada en su `.swm`, la versión (`expected_hash`) que esa dep tiene **en el /// índice** — o sea, contra la que el paquete se construyó. Deps sin `expected_hash` /// en el índice se omiten (no hay versión observable que exigir). pub fn observed_requires( swm: &Swm, index: &crate::RepoIndex, ) -> std::collections::BTreeMap { let mut req = std::collections::BTreeMap::new(); for m in &swm.mutations { if let Mutation::SourcePatch { deps, .. } = m { for dep in &deps.runtime { if let Some(h) = index.find(dep).and_then(|e| e.expected_hash.clone()) { req.insert(dep.clone(), h); } } } } req } /// Un conflicto de versión observado: una dep de runtime está instalada a un hash /// **distinto** del que el paquete espera (el que hay en el repo). Es el caso /// *wayland* derivado: divergiste una superficie de la que este paquete depende. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConflictoVersion { pub dep: String, /// El hash que el paquete espera (la versión del repo contra la que se construyó). pub requerido: String, /// El hash al que la dep está instalada AHORA (divergido). pub instalado: String, } /// Conflictos de versión del entrante contra el estado instalado: por cada /// `requires` observado, si la dep está instalada a otro hash. Una dep que NO está /// instalada no cuenta acá (es un asunto de presencia, no de divergencia) — sólo /// pisar una versión distinta rechaza. Reusa lo que `InstalledDb` ya registra. pub fn version_conflicts( db: &crate::InstalledDb, requires: &std::collections::BTreeMap, ) -> Vec { let mut out = Vec::new(); for (dep, requerido) in requires { if let Some(pkg) = db.find(dep) { if let Some(instalado) = &pkg.hash { if instalado != requerido { out.push(ConflictoVersion { dep: dep.clone(), requerido: requerido.clone(), instalado: instalado.clone(), }); } } } } out } #[cfg(test)] mod tests { use super::*; fn estado(pares: &[(&str, &str)]) -> Estado { pares.iter().map(|(s, h)| (s.to_string(), h.to_string())).collect() } fn slots(claims: &[(&str, &str)], requires: &[(&str, &str)]) -> Slots { Slots { claims: claims.iter().map(|(s, h)| (s.to_string(), h.to_string())).collect(), requires: requires.iter().map(|(s, h)| (s.to_string(), h.to_string())).collect(), } } #[test] fn logo_ya_cambiado_es_eleccion() { let mio = estado(&[("logo", "b3:rojo")]); let entrante = slots(&[("logo", "b3:azul")], &[]); match evaluar(&mio, &entrante) { Veredicto::Colision { slot, instalado, entrante } => { assert_eq!(slot, "logo"); assert_eq!(instalado, "b3:rojo"); assert_eq!(entrante, "b3:azul"); } otro => panic!("esperaba Colision, fue {otro:?}"), } } #[test] fn reclamar_lo_mismo_es_idempotente() { let mio = estado(&[("logo", "b3:rojo")]); assert!(evaluar(&mio, &slots(&[("logo", "b3:rojo")], &[])).es_compatible()); } #[test] fn wayland_divergido_rechaza_dependiente() { let mio = estado(&[("wayland-protocol", "b3:mio")]); let descargado = slots(&[("app-foo", "b3:v1")], &[("wayland-protocol", "b3:stock")]); match evaluar(&mio, &descargado) { Veredicto::Incompatible { slot, requerido, presente } => { assert_eq!(slot, "wayland-protocol"); assert_eq!(requerido, "b3:stock"); assert_eq!(presente, Some("b3:mio".to_string())); } otro => panic!("esperaba Incompatible, fue {otro:?}"), } // Con el wayland stock, la misma descarga es compatible. let stock = estado(&[("wayland-protocol", "b3:stock")]); assert!(evaluar(&stock, &descargado).es_compatible()); } #[test] fn requisito_ausente_incompatible() { let vacio = estado(&[]); match evaluar(&vacio, &slots(&[], &[("wayland-protocol", "b3:stock")])) { Veredicto::Incompatible { presente, .. } => assert_eq!(presente, None), otro => panic!("esperaba Incompatible/None, fue {otro:?}"), } } // ── H4c: superficies observadas (paths escritos, sin declarar slots) ── fn pkg_con_files(name: &str, files: &[&str]) -> crate::InstalledPackage { crate::InstalledPackage { name: name.into(), version: "1".into(), hash: None, files: files.iter().map(|s| s.to_string()).collect(), claims: Default::default(), } } #[test] fn colision_de_path_observada_contra_otro_paquete() { let mut db = crate::InstalledDb::default(); db.record(pkg_con_files("tema-claro", &["/usr/share/logo.png"])); // 'tema-oscuro' escribirá el mismo path ⇒ colisión con 'tema-claro'. let entrante: BTreeSet = ["/usr/share/logo.png".to_string()].into_iter().collect(); let cols = path_collisions(&db, "tema-oscuro", &entrante); assert_eq!(cols.len(), 1); assert_eq!(cols[0].path, "/usr/share/logo.png"); assert_eq!(cols[0].propietario, "tema-claro"); } #[test] fn reinstalar_el_mismo_paquete_no_colisiona() { let mut db = crate::InstalledDb::default(); db.record(pkg_con_files("tema-oscuro", &["/usr/share/logo.png"])); // El mismo paquete sobre sus propios paths = upgrade, no colisión. let paths: BTreeSet = ["/usr/share/logo.png".to_string()].into_iter().collect(); assert!(path_collisions(&db, "tema-oscuro", &paths).is_empty()); } // ── H4e: requires observados (versión de la dep contra la que se construyó) ── #[test] fn wayland_divergido_observado_da_conflicto_de_version() { // El paquete 'app' depende (runtime) de 'wayland-protocol'. En el repo, wayland está a // b3:stock (la versión contra la que app se construyó). let swm = crate::Swm { swm_version: 1, base: crate::swm::Base { distro_version: "dev".into(), pins: Default::default() }, mutations: vec![Mutation::SourcePatch { repo: Some("git://x/app.git".into()), commit: Some("abc".into()), tarball: None, sha256: None, strip_components: None, patch: None, patch_url: None, build: crate::swm::SwmBuild { compiler: "zig-cc".into(), target: "x86_64-linux-musl".into(), link: "static".into(), flags: vec![], phases: Default::default(), zig_version: None, strip_debug: None, }, target_bin: "/usr/bin/app".into(), expected_hash: Some("b3:app".into()), deps: crate::recipe::Deps { build: vec![], runtime: vec!["wayland-protocol".into()] }, evidence: Default::default(), slots: Default::default(), }], signature: None, }; let index: crate::RepoIndex = serde_json::from_str( r#"{"packages":[{"name":"wayland-protocol","version":"1","file":"w.swm","distro_version":"dev","deps":[],"expected_hash":"b3:stock"}]}"#, ) .unwrap(); let req = observed_requires(&swm, &index); assert_eq!(req.get("wayland-protocol").unwrap(), "b3:stock"); // Tengo wayland instalado a b3:mine (divergido) ⇒ conflicto. let mut db = crate::InstalledDb::default(); db.record(crate::InstalledPackage { name: "wayland-protocol".into(), version: "1".into(), hash: Some("b3:mine".into()), files: vec![], claims: Default::default(), }); let cs = version_conflicts(&db, &req); assert_eq!(cs.len(), 1); assert_eq!(cs[0].requerido, "b3:stock"); assert_eq!(cs[0].instalado, "b3:mine"); // Si tengo la versión stock, no hay conflicto. let mut ok = crate::InstalledDb::default(); ok.record(crate::InstalledPackage { name: "wayland-protocol".into(), version: "1".into(), hash: Some("b3:stock".into()), files: vec![], claims: Default::default(), }); assert!(version_conflicts(&ok, &req).is_empty()); } #[test] fn output_paths_lee_target_bin_y_file_drop() { let swm = crate::Swm { swm_version: 1, base: crate::swm::Base { distro_version: "2026-06-06".into(), pins: Default::default(), }, mutations: vec![ Mutation::FileDrop { path: "/usr/share/logo.png".into(), content_hash: "b3:x".into(), content_b64: Some("AA==".into()), content_url: None, }, Mutation::ConfigEdit { file: "/etc/foo.conf".into(), inline_diff: "".into(), }, ], signature: None, }; let paths = output_paths(&swm); // El file_drop cuenta; el config_edit (modifica ajeno) NO. assert!(paths.contains("/usr/share/logo.png")); assert!(!paths.contains("/etc/foo.conf")); assert_eq!(paths.len(), 1); } }