sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja
1//! La **base de datos de paquetes instalados** (Etapa F): qué paquetes hay puestos en un root y
2//! qué ficheros aportó cada uno. Es lo que hace posible `takana uninstall`: sin un registro de
3//! "este paquete creó estos ficheros", quitar un paquete sería adivinar.
4//!
5//! Modelo simple y honesto: `install` registra los ficheros que CREÓ (los hidratados del
6//! `source_patch` + `file_drop` + `init_rule`; un `config_edit` MODIFICA un fichero ajeno, no lo
7//! crea, así que no se registra ni se deshace). `uninstall` borra los ficheros del paquete, salvo
8//! los que OTRO paquete instalado también aporta (refcount por ruta) — nunca rompe a un tercero.
9//!
10//! Las rutas se guardan ABSOLUTAS (bajo el prefix o `/` según donde se instaló), así `uninstall`
11//! no necesita saber el root: borra exactamente lo que registró el `install`.
12
13use std::path::Path;
14
15use serde::{Deserialize, Serialize};
16
17/// Nombre por defecto de la base de datos de instalados.
18pub const DEFAULT_DB: &str = "/var/lib/hammer/installed.json";
19
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21pub struct InstalledDb {
22 #[serde(default)]
23 pub packages: Vec<InstalledPackage>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct InstalledPackage {
28 pub name: String,
29 pub version: String,
30 /// `expected_hash` del paquete (si lo tenía) — sólo informativo en la DB.
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub hash: Option<String>,
33 /// Rutas ABSOLUTAS que este paquete creó (orden de instalación). Lo que `uninstall` borra.
34 pub files: Vec<String>,
35 /// Superficies del sistema que este paquete **reclama** → hash del contenido (H4b, SDD 15 §H4).
36 /// Vacío por defecto (compatibilidad hacia atrás: una DB previa a H4b no lo tiene). Con esto,
37 /// `system_state` deriva el `slot → hash` instalado que el gate de compatibilidad consulta.
38 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
39 pub claims: std::collections::BTreeMap<String, String>,
40}
41
42impl InstalledDb {
43 /// Carga la DB. Un fichero ausente es una DB VACÍA válida (nada instalado todavía).
44 pub fn load(path: impl AsRef<Path>) -> crate::Result<InstalledDb> {
45 match std::fs::read_to_string(path.as_ref()) {
46 Ok(text) => serde_json::from_str(&text)
47 .map_err(|e| crate::Error::Serde(format!("{}: {e}", path.as_ref().display()))),
48 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(InstalledDb::default()),
49 Err(e) => Err(crate::Error::Io(e)),
50 }
51 }
52
53 /// Escribe la DB (creando el directorio padre si hace falta). JSON con saltos, diffeable.
54 pub fn save(&self, path: impl AsRef<Path>) -> crate::Result<()> {
55 let path = path.as_ref();
56 if let Some(parent) = path.parent() {
57 std::fs::create_dir_all(parent)?;
58 }
59 let text = serde_json::to_string_pretty(self)
60 .map_err(|e| crate::Error::Serde(e.to_string()))?;
61 std::fs::write(path, text)?;
62 Ok(())
63 }
64
65 pub fn find(&self, name: &str) -> Option<&InstalledPackage> {
66 self.packages.iter().find(|p| p.name == name)
67 }
68
69 /// Registra (o reemplaza por nombre) un paquete instalado. Re-instalar actualiza la lista de
70 /// ficheros (una versión nueva puede aportar otros).
71 pub fn record(&mut self, pkg: InstalledPackage) {
72 if let Some(slot) = self.packages.iter_mut().find(|p| p.name == pkg.name) {
73 *slot = pkg;
74 } else {
75 self.packages.push(pkg);
76 self.packages.sort_by(|a, b| a.name.cmp(&b.name));
77 }
78 }
79
80 /// Quita el registro de un paquete y lo devuelve (para que el caller borre sus ficheros).
81 pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> {
82 let i = self.packages.iter().position(|p| p.name == name)?;
83 Some(self.packages.remove(i))
84 }
85
86 /// El **estado del sistema** como `slot → hash`: la unión de los `claims` de todos los
87 /// paquetes instalados (H4b, SDD 15 §H4). Es el `Estado` contra el que el gate de
88 /// compatibilidad evalúa una config entrante. Un slot reclamado por dos paquetes no debería
89 /// pasar (el gate lo impide al instalar); si aún así ocurre, gana el orden de la lista (ya
90 /// ordenada por nombre) — determinista.
91 pub fn system_state(&self) -> std::collections::BTreeMap<String, String> {
92 let mut estado = std::collections::BTreeMap::new();
93 for p in &self.packages {
94 for (slot, hash) in &p.claims {
95 estado.entry(slot.clone()).or_insert_with(|| hash.clone());
96 }
97 }
98 estado
99 }
100
101 /// El primer paquete instalado que posee `file` (aportó ese path), si alguno. Lo usa el gate
102 /// de compatibilidad observada (H4c) para nombrar al propietario de una colisión de path.
103 pub fn owner_of(&self, file: &str) -> Option<&str> {
104 self.packages
105 .iter()
106 .find(|p| p.files.iter().any(|f| f == file))
107 .map(|p| p.name.as_str())
108 }
109
110 /// ¿Algún OTRO paquete instalado (≠ `name`) aporta también `file`? Si sí, `uninstall` no debe
111 /// borrarlo (lo seguiría necesitando ese tercero).
112 pub fn owned_by_others(&self, name: &str, file: &str) -> bool {
113 self.packages
114 .iter()
115 .any(|p| p.name != name && p.files.iter().any(|f| f == file))
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 fn pkg(name: &str, files: &[&str]) -> InstalledPackage {
124 InstalledPackage {
125 name: name.into(),
126 version: "1".into(),
127 hash: None,
128 files: files.iter().map(|s| s.to_string()).collect(),
129 claims: Default::default(),
130 }
131 }
132
133 #[test]
134 fn system_state_une_los_claims() {
135 let mut db = InstalledDb::default();
136 db.record(InstalledPackage {
137 name: "tema-oscuro".into(),
138 version: "1".into(),
139 hash: None,
140 files: vec![],
141 claims: [("logo".to_string(), "b3:rojo".to_string())]
142 .into_iter()
143 .collect(),
144 });
145 let estado = db.system_state();
146 assert_eq!(estado.get("logo"), Some(&"b3:rojo".to_string()));
147 }
148
149 #[test]
150 fn load_missing_is_empty() {
151 let d = tempfile::tempdir().unwrap();
152 let db = InstalledDb::load(d.path().join("installed.json")).unwrap();
153 assert!(db.packages.is_empty());
154 }
155
156 #[test]
157 fn record_save_load_find() {
158 let d = tempfile::tempdir().unwrap();
159 let p = d.path().join("installed.json");
160 let mut db = InstalledDb::default();
161 db.record(pkg("ripgrep", &["/usr/bin/rg"]));
162 db.save(&p).unwrap();
163 let back = InstalledDb::load(&p).unwrap();
164 assert_eq!(back.find("ripgrep").unwrap().files, vec!["/usr/bin/rg"]);
165 assert!(back.find("nope").is_none());
166 }
167
168 #[test]
169 fn remove_returns_entry() {
170 let mut db = InstalledDb::default();
171 db.record(pkg("rg", &["/usr/bin/rg"]));
172 let removed = db.remove("rg").unwrap();
173 assert_eq!(removed.files, vec!["/usr/bin/rg"]);
174 assert!(db.find("rg").is_none());
175 assert!(db.remove("rg").is_none());
176 }
177
178 #[test]
179 fn owned_by_others_refcount() {
180 let mut db = InstalledDb::default();
181 db.record(pkg("a", &["/usr/lib/libshared.so", "/usr/bin/a"]));
182 db.record(pkg("b", &["/usr/lib/libshared.so", "/usr/bin/b"]));
183 // El .so lo aportan ambos: al desinstalar 'a' no debe borrarse.
184 assert!(db.owned_by_others("a", "/usr/lib/libshared.so"));
185 // El binario propio de 'a' no lo aporta nadie más.
186 assert!(!db.owned_by_others("a", "/usr/bin/a"));
187 }
188}