//! H4b — el gate de **compatibilidad de configs** sobre la receta real (SDD 15 §H4). //! //! Sube a hammer 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 } #[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()); } #[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); } }