tawasuyu

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…

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

⎇ main crates/takana-core/src/installed.rs Rust · 188 líneas · 7.6 KB Historia Quién tocó qué crudo

takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja b818f5249 · Sergio ·

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}