//! La **base de datos de paquetes instalados** (Etapa F): qué paquetes hay puestos en un root y //! qué ficheros aportó cada uno. Es lo que hace posible `takana uninstall`: sin un registro de //! "este paquete creó estos ficheros", quitar un paquete sería adivinar. //! //! Modelo simple y honesto: `install` registra los ficheros que CREÓ (los hidratados del //! `source_patch` + `file_drop` + `init_rule`; un `config_edit` MODIFICA un fichero ajeno, no lo //! crea, así que no se registra ni se deshace). `uninstall` borra los ficheros del paquete, salvo //! los que OTRO paquete instalado también aporta (refcount por ruta) — nunca rompe a un tercero. //! //! Las rutas se guardan ABSOLUTAS (bajo el prefix o `/` según donde se instaló), así `uninstall` //! no necesita saber el root: borra exactamente lo que registró el `install`. use std::path::Path; use serde::{Deserialize, Serialize}; /// Nombre por defecto de la base de datos de instalados. pub const DEFAULT_DB: &str = "/var/lib/hammer/installed.json"; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct InstalledDb { #[serde(default)] pub packages: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct InstalledPackage { pub name: String, pub version: String, /// `expected_hash` del paquete (si lo tenía) — sólo informativo en la DB. #[serde(default, skip_serializing_if = "Option::is_none")] pub hash: Option, /// Rutas ABSOLUTAS que este paquete creó (orden de instalación). Lo que `uninstall` borra. pub files: Vec, /// Superficies del sistema que este paquete **reclama** → hash del contenido (H4b, SDD 15 §H4). /// Vacío por defecto (compatibilidad hacia atrás: una DB previa a H4b no lo tiene). Con esto, /// `system_state` deriva el `slot → hash` instalado que el gate de compatibilidad consulta. #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub claims: std::collections::BTreeMap, } impl InstalledDb { /// Carga la DB. Un fichero ausente es una DB VACÍA válida (nada instalado todavía). pub fn load(path: impl AsRef) -> crate::Result { match std::fs::read_to_string(path.as_ref()) { Ok(text) => serde_json::from_str(&text) .map_err(|e| crate::Error::Serde(format!("{}: {e}", path.as_ref().display()))), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(InstalledDb::default()), Err(e) => Err(crate::Error::Io(e)), } } /// Escribe la DB (creando el directorio padre si hace falta). JSON con saltos, diffeable. pub fn save(&self, path: impl AsRef) -> crate::Result<()> { let path = path.as_ref(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let text = serde_json::to_string_pretty(self) .map_err(|e| crate::Error::Serde(e.to_string()))?; std::fs::write(path, text)?; Ok(()) } pub fn find(&self, name: &str) -> Option<&InstalledPackage> { self.packages.iter().find(|p| p.name == name) } /// Registra (o reemplaza por nombre) un paquete instalado. Re-instalar actualiza la lista de /// ficheros (una versión nueva puede aportar otros). pub fn record(&mut self, pkg: InstalledPackage) { if let Some(slot) = self.packages.iter_mut().find(|p| p.name == pkg.name) { *slot = pkg; } else { self.packages.push(pkg); self.packages.sort_by(|a, b| a.name.cmp(&b.name)); } } /// Quita el registro de un paquete y lo devuelve (para que el caller borre sus ficheros). pub fn remove(&mut self, name: &str) -> Option { let i = self.packages.iter().position(|p| p.name == name)?; Some(self.packages.remove(i)) } /// El **estado del sistema** como `slot → hash`: la unión de los `claims` de todos los /// paquetes instalados (H4b, SDD 15 §H4). Es el `Estado` contra el que el gate de /// compatibilidad evalúa una config entrante. Un slot reclamado por dos paquetes no debería /// pasar (el gate lo impide al instalar); si aún así ocurre, gana el orden de la lista (ya /// ordenada por nombre) — determinista. pub fn system_state(&self) -> std::collections::BTreeMap { let mut estado = std::collections::BTreeMap::new(); for p in &self.packages { for (slot, hash) in &p.claims { estado.entry(slot.clone()).or_insert_with(|| hash.clone()); } } estado } /// El primer paquete instalado que posee `file` (aportó ese path), si alguno. Lo usa el gate /// de compatibilidad observada (H4c) para nombrar al propietario de una colisión de path. pub fn owner_of(&self, file: &str) -> Option<&str> { self.packages .iter() .find(|p| p.files.iter().any(|f| f == file)) .map(|p| p.name.as_str()) } /// ¿Algún OTRO paquete instalado (≠ `name`) aporta también `file`? Si sí, `uninstall` no debe /// borrarlo (lo seguiría necesitando ese tercero). pub fn owned_by_others(&self, name: &str, file: &str) -> bool { self.packages .iter() .any(|p| p.name != name && p.files.iter().any(|f| f == file)) } } #[cfg(test)] mod tests { use super::*; fn pkg(name: &str, files: &[&str]) -> InstalledPackage { InstalledPackage { name: name.into(), version: "1".into(), hash: None, files: files.iter().map(|s| s.to_string()).collect(), claims: Default::default(), } } #[test] fn system_state_une_los_claims() { let mut db = InstalledDb::default(); db.record(InstalledPackage { name: "tema-oscuro".into(), version: "1".into(), hash: None, files: vec![], claims: [("logo".to_string(), "b3:rojo".to_string())] .into_iter() .collect(), }); let estado = db.system_state(); assert_eq!(estado.get("logo"), Some(&"b3:rojo".to_string())); } #[test] fn load_missing_is_empty() { let d = tempfile::tempdir().unwrap(); let db = InstalledDb::load(d.path().join("installed.json")).unwrap(); assert!(db.packages.is_empty()); } #[test] fn record_save_load_find() { let d = tempfile::tempdir().unwrap(); let p = d.path().join("installed.json"); let mut db = InstalledDb::default(); db.record(pkg("ripgrep", &["/usr/bin/rg"])); db.save(&p).unwrap(); let back = InstalledDb::load(&p).unwrap(); assert_eq!(back.find("ripgrep").unwrap().files, vec!["/usr/bin/rg"]); assert!(back.find("nope").is_none()); } #[test] fn remove_returns_entry() { let mut db = InstalledDb::default(); db.record(pkg("rg", &["/usr/bin/rg"])); let removed = db.remove("rg").unwrap(); assert_eq!(removed.files, vec!["/usr/bin/rg"]); assert!(db.find("rg").is_none()); assert!(db.remove("rg").is_none()); } #[test] fn owned_by_others_refcount() { let mut db = InstalledDb::default(); db.record(pkg("a", &["/usr/lib/libshared.so", "/usr/bin/a"])); db.record(pkg("b", &["/usr/lib/libshared.so", "/usr/bin/b"])); // El .so lo aportan ambos: al desinstalar 'a' no debe borrarse. assert!(db.owned_by_others("a", "/usr/lib/libshared.so")); // El binario propio de 'a' no lo aporta nadie más. assert!(!db.owned_by_others("a", "/usr/bin/a")); } }