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…
⎇ 8913eae26bb010a2de486dfd9f317c4921c6edc4
crates/hammer-core/src/installed.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
Etapa F paquetería #5: DB de instalados + `hammer uninstall`/`installed`
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 `hammer 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}
36
37impl InstalledDb {
38 /// Carga la DB. Un fichero ausente es una DB VACÍA válida (nada instalado todavía).
39 pub fn load(path: impl AsRef<Path>) -> crate::Result<InstalledDb> {
40 match std::fs::read_to_string(path.as_ref()) {
41 Ok(text) => serde_json::from_str(&text)
42 .map_err(|e| crate::Error::Serde(format!("{}: {e}", path.as_ref().display()))),
43 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(InstalledDb::default()),
44 Err(e) => Err(crate::Error::Io(e)),
45 }
46 }
47
48 /// Escribe la DB (creando el directorio padre si hace falta). JSON con saltos, diffeable.
49 pub fn save(&self, path: impl AsRef<Path>) -> crate::Result<()> {
50 let path = path.as_ref();
51 if let Some(parent) = path.parent() {
52 std::fs::create_dir_all(parent)?;
53 }
54 let text = serde_json::to_string_pretty(self)
55 .map_err(|e| crate::Error::Serde(e.to_string()))?;
56 std::fs::write(path, text)?;
57 Ok(())
58 }
59
60 pub fn find(&self, name: &str) -> Option<&InstalledPackage> {
61 self.packages.iter().find(|p| p.name == name)
62 }
63
64 /// Registra (o reemplaza por nombre) un paquete instalado. Re-instalar actualiza la lista de
65 /// ficheros (una versión nueva puede aportar otros).
66 pub fn record(&mut self, pkg: InstalledPackage) {
67 if let Some(slot) = self.packages.iter_mut().find(|p| p.name == pkg.name) {
68 *slot = pkg;
69 } else {
70 self.packages.push(pkg);
71 self.packages.sort_by(|a, b| a.name.cmp(&b.name));
72 }
73 }
74
75 /// Quita el registro de un paquete y lo devuelve (para que el caller borre sus ficheros).
76 pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> {
77 let i = self.packages.iter().position(|p| p.name == name)?;
78 Some(self.packages.remove(i))
79 }
80
81 /// ¿Algún OTRO paquete instalado (≠ `name`) aporta también `file`? Si sí, `uninstall` no debe
82 /// borrarlo (lo seguiría necesitando ese tercero).
83 pub fn owned_by_others(&self, name: &str, file: &str) -> bool {
84 self.packages
85 .iter()
86 .any(|p| p.name != name && p.files.iter().any(|f| f == file))
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 fn pkg(name: &str, files: &[&str]) -> InstalledPackage {
95 InstalledPackage {
96 name: name.into(),
97 version: "1".into(),
98 hash: None,
99 files: files.iter().map(|s| s.to_string()).collect(),
100 }
101 }
102
103 #[test]
104 fn load_missing_is_empty() {
105 let d = tempfile::tempdir().unwrap();
106 let db = InstalledDb::load(d.path().join("installed.json")).unwrap();
107 assert!(db.packages.is_empty());
108 }
109
110 #[test]
111 fn record_save_load_find() {
112 let d = tempfile::tempdir().unwrap();
113 let p = d.path().join("installed.json");
114 let mut db = InstalledDb::default();
115 db.record(pkg("ripgrep", &["/usr/bin/rg"]));
116 db.save(&p).unwrap();
117 let back = InstalledDb::load(&p).unwrap();
118 assert_eq!(back.find("ripgrep").unwrap().files, vec!["/usr/bin/rg"]);
119 assert!(back.find("nope").is_none());
120 }
121
122 #[test]
123 fn remove_returns_entry() {
124 let mut db = InstalledDb::default();
125 db.record(pkg("rg", &["/usr/bin/rg"]));
126 let removed = db.remove("rg").unwrap();
127 assert_eq!(removed.files, vec!["/usr/bin/rg"]);
128 assert!(db.find("rg").is_none());
129 assert!(db.remove("rg").is_none());
130 }
131
132 #[test]
133 fn owned_by_others_refcount() {
134 let mut db = InstalledDb::default();
135 db.record(pkg("a", &["/usr/lib/libshared.so", "/usr/bin/a"]));
136 db.record(pkg("b", &["/usr/lib/libshared.so", "/usr/bin/b"]));
137 // El .so lo aportan ambos: al desinstalar 'a' no debe borrarse.
138 assert!(db.owned_by_others("a", "/usr/lib/libshared.so"));
139 // El binario propio de 'a' no lo aporta nadie más.
140 assert!(!db.owned_by_others("a", "/usr/bin/a"));
141 }
142}