feat(minga): minga-vfs — proyecta el repo como filesystem FUSE

minga-vfs deja de ser un stub: monta el repositorio direccionado por
contenido como un filesystem FUSE de sólo lectura. roots/<hash> da el
código fuente reconstruido (formato normalizado) de cada raíz del MST;
cas/<hash> resuelve cualquier hash bajo demanda como S-expression.

Capas separadas: render (SemanticNode→texto, puro) + source (contrato
NodeSource, backends sled/memoria) + fs (único módulo con fuser).
Nuevo subcomando `minga mount <punto>`. Dep fuser 0.15 sin libfuse-dev
(default-features = false). 14 tests nuevos, sin regresión en minga-cli.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-22 13:23:44 +00:00
parent 762bf95dfd
commit e77a32f4d6
15 changed files with 1094 additions and 14 deletions
@@ -0,0 +1,154 @@
//! El contrato [`NodeSource`] —lo mínimo que el VFS necesita de un
//! repositorio Minga— y sus dos backends. Agnóstico de `fuser`.
//!
//! El VFS no quiere conocer `sled` ni la estructura interna del store:
//! sólo necesita (a) enumerar las raíces del MST y (b) resolver un nodo
//! por hash. Eso es [`NodeSource`]. [`RepoSource`] lo implementa sobre
//! el [`PersistentRepo`] en disco; [`MemSource`] sobre un `MemStore` en
//! RAM (tests, índices efímeros recién sincronizados).
use minga_core::{ContentHash, SemanticNode, StoredNode};
use minga_store::PersistentRepo;
/// Lo que el VFS necesita de un repositorio para proyectarlo.
pub trait NodeSource {
/// Hashes raíz: el conjunto de claves del MST, un elemento por
/// archivo ingerido. Es lo que se lista bajo `roots/`.
fn roots(&self) -> Vec<ContentHash>;
/// Resuelve un único nodo (un eslabón del grafo) por su hash.
/// `None` si no está en el almacén.
fn get(&self, hash: &ContentHash) -> Option<StoredNode>;
}
/// Reconstruye el `SemanticNode` completo de un hash, resolviendo
/// recursivamente sus hijos contra `source`.
///
/// Devuelve `None` si el almacén está incompleto: o el propio `hash`
/// falta, o lo hace algún descendiente (puede ocurrir en un repo a
/// medio sincronizar).
pub fn reconstruct<S>(source: &S, hash: &ContentHash) -> Option<SemanticNode>
where
S: NodeSource + ?Sized,
{
let stored = source.get(hash)?;
let mut children = Vec::with_capacity(stored.children.len());
for child in &stored.children {
children.push(reconstruct(source, child)?);
}
Some(SemanticNode {
kind: stored.kind,
field_name: stored.field_name,
leaf_text: stored.leaf_text,
children,
})
}
/// [`NodeSource`] respaldado por un [`PersistentRepo`] de `minga-store`
/// (almacén `sled` en disco). Es la fuente que usa `minga mount`.
pub struct RepoSource {
repo: PersistentRepo,
}
impl RepoSource {
/// Envuelve un repo ya abierto. La propiedad pasa al `RepoSource`:
/// el repo se cierra cuando éste se dropea.
pub fn new(repo: PersistentRepo) -> Self {
Self { repo }
}
}
impl NodeSource for RepoSource {
fn roots(&self) -> Vec<ContentHash> {
// Las claves del MST corruptas (si las hubiera) se descartan en
// silencio: un par de entradas ilegibles no deben tirar el `ls`.
self.repo.mst.iter().filter_map(Result::ok).collect()
}
fn get(&self, hash: &ContentHash) -> Option<StoredNode> {
self.repo.nodes.get(hash).ok().flatten()
}
}
/// [`NodeSource`] en memoria: un `MemStore` más un conjunto explícito
/// de raíces. Para tests y para montar índices que viven sólo en RAM.
#[derive(Default)]
pub struct MemSource {
store: minga_core::MemStore,
roots: Vec<ContentHash>,
}
impl MemSource {
pub fn new() -> Self {
Self::default()
}
/// Inserta un árbol como raíz (un "archivo") y devuelve su hash.
/// Idempotente: ingerir dos veces el mismo árbol no lo duplica.
pub fn add_root(&mut self, node: &SemanticNode) -> ContentHash {
use minga_core::NodeStore;
let hash = self.store.put(node);
if !self.roots.contains(&hash) {
self.roots.push(hash);
}
hash
}
}
impl NodeSource for MemSource {
fn roots(&self) -> Vec<ContentHash> {
self.roots.clone()
}
fn get(&self, hash: &ContentHash) -> Option<StoredNode> {
use minga_core::NodeStore;
self.store.get(hash).cloned()
}
}
#[cfg(test)]
mod tests {
use super::*;
use minga_core::ast::SemanticNode;
fn leaf(kind: &str, text: &str) -> SemanticNode {
SemanticNode {
kind: kind.to_string(),
field_name: None,
leaf_text: Some(text.as_bytes().to_vec()),
children: Vec::new(),
}
}
#[test]
fn mem_source_reconstructs_what_it_stored() {
let tree = SemanticNode {
kind: "root".to_string(),
field_name: None,
leaf_text: None,
children: vec![leaf("a", "1"), leaf("b", "2")],
};
let mut src = MemSource::new();
let hash = src.add_root(&tree);
assert_eq!(src.roots(), vec![hash]);
let back = reconstruct(&src, &hash).expect("debe reconstruir");
assert_eq!(back, tree);
}
#[test]
fn add_root_is_idempotent() {
let tree = leaf("only", "x");
let mut src = MemSource::new();
let h1 = src.add_root(&tree);
let h2 = src.add_root(&tree);
assert_eq!(h1, h2);
assert_eq!(src.roots().len(), 1);
}
#[test]
fn unknown_hash_reconstructs_to_none() {
let src = MemSource::new();
assert!(reconstruct(&src, &ContentHash([0u8; 32])).is_none());
}
}