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:
+1
-1
@@ -6,7 +6,7 @@ subdirectorio del workspace.
|
|||||||
|
|
||||||
- [`chasqui`](docs/changelog/chasqui.md) — 15 entradas
|
- [`chasqui`](docs/changelog/chasqui.md) — 15 entradas
|
||||||
- [`init`](docs/changelog/init.md) — 1 entradas
|
- [`init`](docs/changelog/init.md) — 1 entradas
|
||||||
- [`minga`](docs/changelog/minga.md) — 5 entradas
|
- [`minga`](docs/changelog/minga.md) — 6 entradas
|
||||||
- [`misc`](docs/changelog/misc.md) — 2 entradas
|
- [`misc`](docs/changelog/misc.md) — 2 entradas
|
||||||
- [`nahual`](docs/changelog/nahual.md) — 17 entradas
|
- [`nahual`](docs/changelog/nahual.md) — 17 entradas
|
||||||
- [`nakui`](docs/changelog/nakui.md) — 20 entradas
|
- [`nakui`](docs/changelog/nakui.md) — 20 entradas
|
||||||
|
|||||||
Generated
+30
@@ -4690,6 +4690,21 @@ version = "2.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fuser"
|
||||||
|
version = "0.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53274f494609e77794b627b1a3cddfe45d675a6b2e9ba9c0fdc8d8eee2184369"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"memchr",
|
||||||
|
"nix 0.29.0",
|
||||||
|
"page_size",
|
||||||
|
"smallvec",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futf"
|
name = "futf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -7676,6 +7691,7 @@ dependencies = [
|
|||||||
"minga-core",
|
"minga-core",
|
||||||
"minga-p2p",
|
"minga-p2p",
|
||||||
"minga-store",
|
"minga-store",
|
||||||
|
"minga-vfs",
|
||||||
"notify",
|
"notify",
|
||||||
"rpassword",
|
"rpassword",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -7753,7 +7769,11 @@ dependencies = [
|
|||||||
name = "minga-vfs"
|
name = "minga-vfs"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"fuser",
|
||||||
|
"libc",
|
||||||
"minga-core",
|
"minga-core",
|
||||||
|
"minga-store",
|
||||||
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9501,6 +9521,16 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "page_size"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pageant"
|
name = "pageant"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -380,6 +380,11 @@ tree-sitter-go = "0.23"
|
|||||||
# === FS notify ===
|
# === FS notify ===
|
||||||
notify = "6.1"
|
notify = "6.1"
|
||||||
|
|
||||||
|
# === FUSE (minga-vfs) ===
|
||||||
|
# default-features = false: prescinde de pkg-config/libfuse-dev en build.
|
||||||
|
# El montaje pasa a ser Rust puro (vía el helper `fusermount3` en runtime).
|
||||||
|
fuser = { version = "0.15", default-features = false }
|
||||||
|
|
||||||
# === CLI / auth (minga) ===
|
# === CLI / auth (minga) ===
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
|
|||||||
@@ -11,14 +11,17 @@ y los publica en un DHT. Búsqueda por similitud semántica cross-repo.
|
|||||||
| `minga-core` | lib | Parser + α-hashing (Rust/Py/TS/JS/Go) + scoring |
|
| `minga-core` | lib | Parser + α-hashing (Rust/Py/TS/JS/Go) + scoring |
|
||||||
| `minga-store` | lib | Sled backend para índice local |
|
| `minga-store` | lib | Sled backend para índice local |
|
||||||
| `minga-p2p` | lib | Capa libp2p: kad + bootstrap + provider records |
|
| `minga-p2p` | lib | Capa libp2p: kad + bootstrap + provider records |
|
||||||
| `minga-vfs` | lib | Stub (2 LOC) — FUSE que proyecta el índice como filesystem |
|
| `minga-vfs` | lib | FUSE: monta el repo como filesystem de sólo lectura |
|
||||||
| `minga-cli` | bin | CLI: index, search, peers, bootstrap |
|
| `minga-cli` | bin | CLI: init, status, ingest, listen, sync, watch, mount |
|
||||||
|
|
||||||
## Dependencias
|
## Dependencias
|
||||||
|
|
||||||
- `minga-core` ← `tree-sitter`, `tree-sitter-rust`, `-python`, `-ts`,
|
- `minga-core` ← `tree-sitter`, `tree-sitter-rust`, `-python`, `-ts`,
|
||||||
`-js`, `-go`. Consumido por store/p2p/cli.
|
`-js`, `-go`. Consumido por store/p2p/vfs/cli.
|
||||||
- `minga-p2p` ← `protocol/brahman-net` (libp2p compartido).
|
- `minga-p2p` ← `protocol/brahman-net` (libp2p compartido).
|
||||||
|
- `minga-vfs` ← `minga-store` (lee el `PersistentRepo`), `fuser`
|
||||||
|
(`default-features = false`: sin pkg-config/libfuse-dev en build),
|
||||||
|
`libc`.
|
||||||
|
|
||||||
## α-hashing
|
## α-hashing
|
||||||
|
|
||||||
@@ -27,10 +30,34 @@ implementaciones idénticas con identifiers distintos colisionen en el
|
|||||||
mismo hash. Cubre let-else, if-let, or-patterns, let-chains en Rust;
|
mismo hash. Cubre let-else, if-let, or-patterns, let-chains en Rust;
|
||||||
cierres en Py/TS/JS/Go.
|
cierres en Py/TS/JS/Go.
|
||||||
|
|
||||||
|
## minga-vfs — proyección como filesystem
|
||||||
|
|
||||||
|
`minga mount <punto>` monta el repo direccionado por contenido como un
|
||||||
|
filesystem FUSE de sólo lectura. Layout:
|
||||||
|
|
||||||
|
```text
|
||||||
|
README explicación del propio montaje
|
||||||
|
roots/<hash> código fuente reconstruido (formato normalizado) de
|
||||||
|
cada raíz del MST — `ls roots/` las lista todas
|
||||||
|
cas/<hash> S-expression del subárbol de ese hash — el directorio
|
||||||
|
NO se enumera, pero `cat cas/<hash>` resuelve cualquier
|
||||||
|
hash conocido (el "blob por hash bajo demanda")
|
||||||
|
```
|
||||||
|
|
||||||
|
El render del fuente es **normalizado**, no byte-exacto: el AST
|
||||||
|
descartó whitespace y comentarios al ingerir, así que `roots/<hash>`
|
||||||
|
re-indenta por estructura de llaves. Python (cuya estructura vive en
|
||||||
|
la indentación) sale como secuencia de tokens.
|
||||||
|
|
||||||
|
Capas (separabilidad): `render` (SemanticNode → texto, puro) +
|
||||||
|
`source` (contrato `NodeSource`, backends sled/memoria) + `fs` (único
|
||||||
|
módulo acoplado a `fuser`).
|
||||||
|
|
||||||
## Estado
|
## Estado
|
||||||
|
|
||||||
LOC 5,091. Indexa repos reales. 15 TODOs en core (más patterns para
|
Pipeline core completo: indexa repos reales, sincroniza P2P, y se
|
||||||
cada lenguaje). `minga-vfs` aún no implementado — bloquea el montaje
|
monta como filesystem. 15 TODOs en core (más patterns para cada
|
||||||
del repo como filesystem (paths virtuales → blobs por hash bajo
|
lenguaje). `minga-vfs` implementado — el montaje del repo como
|
||||||
demanda). NO está relacionado con Mónadas (esas son de `akasha/`).
|
filesystem (paths virtuales → blobs por hash bajo demanda) ya
|
||||||
|
funciona. NO está relacionado con Mónadas (esas son de `akasha/`).
|
||||||
Ver `docs/changelog/minga.md`.
|
Ver `docs/changelog/minga.md`.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
description = "CLI de Minga: init, status, ingest, listen, sync."
|
description = "CLI de Minga: init, status, ingest, listen, sync, watch, mount."
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "minga"
|
name = "minga"
|
||||||
@@ -14,6 +14,7 @@ path = "src/main.rs"
|
|||||||
minga-core = { path = "../minga-core" }
|
minga-core = { path = "../minga-core" }
|
||||||
minga-p2p = { path = "../minga-p2p" }
|
minga-p2p = { path = "../minga-p2p" }
|
||||||
minga-store = { path = "../minga-store" }
|
minga-store = { path = "../minga-store" }
|
||||||
|
minga-vfs = { path = "../minga-vfs" }
|
||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
rpassword = { workspace = true }
|
rpassword = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -97,6 +97,23 @@ pub fn cmd_ingest(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `minga mount <punto>`: monta el repositorio como un filesystem FUSE
|
||||||
|
/// de sólo lectura. Cada hash del store se vuelve un archivo
|
||||||
|
/// navegable con `ls`/`cat`. Bloquea hasta que se desmonte el punto
|
||||||
|
/// (`fusermount -u <punto>` o una señal al proceso).
|
||||||
|
pub fn cmd_mount(
|
||||||
|
repo_path: &Path,
|
||||||
|
passphrase: &str,
|
||||||
|
mountpoint: &Path,
|
||||||
|
) -> Result<(), CliError> {
|
||||||
|
// Cargar el keypair valida la passphrase: montar es navegar el
|
||||||
|
// repo, así que pedimos la misma credencial que `status`/`watch`.
|
||||||
|
let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
|
||||||
|
let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
|
||||||
|
minga_vfs::mount(minga_vfs::RepoSource::new(repo), mountpoint)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Detecta el dialecto desde la extensión del archivo. Error si la
|
/// Detecta el dialecto desde la extensión del archivo. Error si la
|
||||||
/// extensión no corresponde a un lenguaje soportado.
|
/// extensión no corresponde a un lenguaje soportado.
|
||||||
fn detect_dialect(file: &Path) -> Result<parse::Dialect, CliError> {
|
fn detect_dialect(file: &Path) -> Result<parse::Dialect, CliError> {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub mod commands;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
|
|
||||||
pub use commands::{
|
pub use commands::{
|
||||||
cmd_ingest, cmd_init, cmd_listen, cmd_status, cmd_sync, cmd_watch, IngestResult, RepoStatus,
|
cmd_ingest, cmd_init, cmd_listen, cmd_mount, cmd_status, cmd_sync, cmd_watch, IngestResult,
|
||||||
|
RepoStatus,
|
||||||
};
|
};
|
||||||
pub use error::CliError;
|
pub use error::CliError;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::process::ExitCode;
|
|||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use minga_cli::{
|
use minga_cli::{
|
||||||
cmd_ingest, cmd_init, cmd_listen, cmd_status, cmd_sync, cmd_watch, CliError,
|
cmd_ingest, cmd_init, cmd_listen, cmd_mount, cmd_status, cmd_sync, cmd_watch, CliError,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@@ -59,6 +59,14 @@ enum Command {
|
|||||||
/// Directorio a vigilar.
|
/// Directorio a vigilar.
|
||||||
dir: PathBuf,
|
dir: PathBuf,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Monta el repositorio como filesystem FUSE de sólo lectura.
|
||||||
|
/// Cada hash del store se vuelve un archivo navegable con
|
||||||
|
/// `ls`/`cat`. Bloquea hasta `fusermount -u <punto>`.
|
||||||
|
Mount {
|
||||||
|
/// Punto de montaje: un directorio existente.
|
||||||
|
point: PathBuf,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
@@ -116,6 +124,16 @@ fn run() -> Result<(), CliError> {
|
|||||||
.map_err(|e| CliError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
|
.map_err(|e| CliError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
|
||||||
rt.block_on(cmd_watch(&cli.repo, &pass, &dir))?;
|
rt.block_on(cmd_watch(&cli.repo, &pass, &dir))?;
|
||||||
}
|
}
|
||||||
|
Command::Mount { point } => {
|
||||||
|
let pass = prompt_passphrase()?;
|
||||||
|
println!(
|
||||||
|
"Montando {} en {}. `fusermount -u {}` para desmontar.",
|
||||||
|
cli.repo.display(),
|
||||||
|
point.display(),
|
||||||
|
point.display()
|
||||||
|
);
|
||||||
|
cmd_mount(&cli.repo, &pass, &point)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
description = "Virtual File System de Minga (FUSE). Aún no implementado."
|
description = "Virtual File System de Minga: proyecta el repositorio direccionado por contenido como un filesystem FUSE de sólo lectura."
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
minga-core = { path = "../minga-core" }
|
minga-core = { path = "../minga-core" }
|
||||||
|
minga-store = { path = "../minga-store" }
|
||||||
|
fuser = { workspace = true }
|
||||||
|
libc = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
//! Adaptador a `fuser`: el único módulo del crate acoplado a FUSE.
|
||||||
|
//!
|
||||||
|
//! Traduce el contrato [`NodeSource`] a la `Filesystem` trait. El
|
||||||
|
//! filesystem es de sólo lectura y de estructura fija (ver el layout en
|
||||||
|
//! la documentación del crate). Los inodos estáticos (raíz, `README`,
|
||||||
|
//! `roots/`, `cas/`) tienen números reservados; los archivos por hash
|
||||||
|
//! reciben un inodo dinámico la primera vez que se nombran, estable a
|
||||||
|
//! partir de ahí.
|
||||||
|
//!
|
||||||
|
//! `fuser` 0.15 despacha las peticiones de forma secuencial en un único
|
||||||
|
//! hilo de sesión, así que los métodos toman `&mut self` y mutamos los
|
||||||
|
//! mapas internos sin necesidad de locks.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
use fuser::{
|
||||||
|
FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, ReplyStatfs,
|
||||||
|
Request,
|
||||||
|
};
|
||||||
|
use minga_core::ContentHash;
|
||||||
|
|
||||||
|
use crate::render::{render_sexp, render_source};
|
||||||
|
use crate::source::{reconstruct, NodeSource};
|
||||||
|
|
||||||
|
/// TTL de las respuestas cacheadas por el kernel. El contenido es
|
||||||
|
/// inmutable (direccionado por contenido), pero el *conjunto* de raíces
|
||||||
|
/// crece con cada ingest; 1 s es el compromiso habitual.
|
||||||
|
const TTL: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
// Inodos estáticos. Los dinámicos arrancan en INO_DYNAMIC_BASE.
|
||||||
|
const INO_ROOT: u64 = 1;
|
||||||
|
const INO_README: u64 = 2;
|
||||||
|
const INO_ROOTS_DIR: u64 = 3;
|
||||||
|
const INO_CAS_DIR: u64 = 4;
|
||||||
|
const INO_DYNAMIC_BASE: u64 = 16;
|
||||||
|
|
||||||
|
/// Contenido del archivo `/README` del propio montaje.
|
||||||
|
const README: &str = "\
|
||||||
|
Minga VFS — proyección de sólo lectura de un repositorio Minga.
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
roots/<hash> Código fuente reconstruido (formato normalizado) de
|
||||||
|
cada archivo ingerido. `ls roots/` los lista todos.
|
||||||
|
cas/<hash> S-expression del subárbol con ese hash. Este
|
||||||
|
directorio NO se lista (son demasiados nodos), pero
|
||||||
|
`cat cas/<hash>` resuelve cualquier hash conocido.
|
||||||
|
|
||||||
|
El hash es un BLAKE3 de 64 hex en minúsculas sobre la ESTRUCTURA
|
||||||
|
semántica del código: whitespace y comentarios no cuentan. Por eso
|
||||||
|
`roots/<hash>` es una reconstrucción normalizada, no el archivo
|
||||||
|
original byte-a-byte.
|
||||||
|
|
||||||
|
Filesystem de sólo lectura. Desmontar: fusermount -u <punto>.
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Cuál de los dos directorios de hashes; determina el renderizado.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Dir {
|
||||||
|
/// `roots/` — código fuente reconstruido.
|
||||||
|
Roots,
|
||||||
|
/// `cas/` — S-expression del árbol.
|
||||||
|
Cas,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementación de `fuser::Filesystem` sobre un [`NodeSource`].
|
||||||
|
pub struct MingaFs<S: NodeSource> {
|
||||||
|
source: S,
|
||||||
|
/// Siguiente inodo dinámico libre.
|
||||||
|
next_ino: u64,
|
||||||
|
/// `(inodo_padre, nombre)` → inodo dinámico, para que un mismo hash
|
||||||
|
/// conserve su inodo entre llamadas.
|
||||||
|
name_to_ino: HashMap<(u64, String), u64>,
|
||||||
|
/// Inodo dinámico → contenido ya renderizado. Cachea el resultado
|
||||||
|
/// del primer `lookup`/`read` de cada archivo.
|
||||||
|
content: HashMap<u64, Vec<u8>>,
|
||||||
|
/// Marca de tiempo uniforme para todos los atributos.
|
||||||
|
epoch: SystemTime,
|
||||||
|
uid: u32,
|
||||||
|
gid: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: NodeSource> MingaFs<S> {
|
||||||
|
/// Construye el filesystem sobre `source`. Los archivos virtuales
|
||||||
|
/// quedan a nombre del usuario y grupo del proceso, para que pueda
|
||||||
|
/// leerlos sin `allow_other`.
|
||||||
|
pub fn new(source: S) -> Self {
|
||||||
|
Self {
|
||||||
|
source,
|
||||||
|
next_ino: INO_DYNAMIC_BASE,
|
||||||
|
name_to_ino: HashMap::new(),
|
||||||
|
content: HashMap::new(),
|
||||||
|
epoch: SystemTime::now(),
|
||||||
|
// SAFETY: getuid/getgid son siempre seguras, sin efectos.
|
||||||
|
uid: unsafe { libc::getuid() },
|
||||||
|
gid: unsafe { libc::getgid() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inodo dinámico para `(parent, name)`, asignándolo si es la
|
||||||
|
/// primera vez que se ve.
|
||||||
|
fn intern_ino(&mut self, parent: u64, name: &str) -> u64 {
|
||||||
|
if let Some(&ino) = self.name_to_ino.get(&(parent, name.to_string())) {
|
||||||
|
return ino;
|
||||||
|
}
|
||||||
|
let ino = self.next_ino;
|
||||||
|
self.next_ino += 1;
|
||||||
|
self.name_to_ino.insert((parent, name.to_string()), ino);
|
||||||
|
ino
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resuelve un nombre bajo `roots/` o `cas/`: parsea el hash,
|
||||||
|
/// reconstruye el nodo, lo renderiza según `dir`, cachea el
|
||||||
|
/// contenido y devuelve `(inodo, tamaño)`. `None` si el nombre no
|
||||||
|
/// es un hash válido o el nodo no está en el store.
|
||||||
|
fn resolve(&mut self, dir: Dir, parent: u64, name: &str) -> Option<(u64, usize)> {
|
||||||
|
let hash = parse_hash(name)?;
|
||||||
|
let node = reconstruct(&self.source, &hash)?;
|
||||||
|
let rendered = match dir {
|
||||||
|
Dir::Roots => render_source(&node),
|
||||||
|
Dir::Cas => render_sexp(&node),
|
||||||
|
};
|
||||||
|
let bytes = rendered.into_bytes();
|
||||||
|
let size = bytes.len();
|
||||||
|
let ino = self.intern_ino(parent, name);
|
||||||
|
self.content.insert(ino, bytes);
|
||||||
|
Some((ino, size))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dir_attr(&self, ino: u64) -> FileAttr {
|
||||||
|
FileAttr {
|
||||||
|
ino,
|
||||||
|
size: 0,
|
||||||
|
blocks: 0,
|
||||||
|
atime: self.epoch,
|
||||||
|
mtime: self.epoch,
|
||||||
|
ctime: self.epoch,
|
||||||
|
crtime: self.epoch,
|
||||||
|
kind: FileType::Directory,
|
||||||
|
perm: 0o555,
|
||||||
|
nlink: 2,
|
||||||
|
uid: self.uid,
|
||||||
|
gid: self.gid,
|
||||||
|
rdev: 0,
|
||||||
|
blksize: 512,
|
||||||
|
flags: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_attr(&self, ino: u64, size: usize) -> FileAttr {
|
||||||
|
let size = size as u64;
|
||||||
|
FileAttr {
|
||||||
|
ino,
|
||||||
|
size,
|
||||||
|
blocks: size.div_ceil(512),
|
||||||
|
atime: self.epoch,
|
||||||
|
mtime: self.epoch,
|
||||||
|
ctime: self.epoch,
|
||||||
|
crtime: self.epoch,
|
||||||
|
kind: FileType::RegularFile,
|
||||||
|
perm: 0o444,
|
||||||
|
nlink: 1,
|
||||||
|
uid: self.uid,
|
||||||
|
gid: self.gid,
|
||||||
|
rdev: 0,
|
||||||
|
blksize: 512,
|
||||||
|
flags: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: NodeSource> Filesystem for MingaFs<S> {
|
||||||
|
fn lookup(&mut self, _req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEntry) {
|
||||||
|
let Some(name) = name.to_str() else {
|
||||||
|
reply.error(libc::ENOENT);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match parent {
|
||||||
|
INO_ROOT => match name {
|
||||||
|
"README" => reply.entry(&TTL, &self.file_attr(INO_README, README.len()), 0),
|
||||||
|
"roots" => reply.entry(&TTL, &self.dir_attr(INO_ROOTS_DIR), 0),
|
||||||
|
"cas" => reply.entry(&TTL, &self.dir_attr(INO_CAS_DIR), 0),
|
||||||
|
_ => reply.error(libc::ENOENT),
|
||||||
|
},
|
||||||
|
INO_ROOTS_DIR | INO_CAS_DIR => {
|
||||||
|
let dir = if parent == INO_ROOTS_DIR {
|
||||||
|
Dir::Roots
|
||||||
|
} else {
|
||||||
|
Dir::Cas
|
||||||
|
};
|
||||||
|
match self.resolve(dir, parent, name) {
|
||||||
|
Some((ino, size)) => reply.entry(&TTL, &self.file_attr(ino, size), 0),
|
||||||
|
None => reply.error(libc::ENOENT),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Los archivos no tienen hijos.
|
||||||
|
_ => reply.error(libc::ENOENT),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getattr(&mut self, _req: &Request<'_>, ino: u64, _fh: Option<u64>, reply: ReplyAttr) {
|
||||||
|
match ino {
|
||||||
|
INO_ROOT | INO_ROOTS_DIR | INO_CAS_DIR => reply.attr(&TTL, &self.dir_attr(ino)),
|
||||||
|
INO_README => reply.attr(&TTL, &self.file_attr(INO_README, README.len())),
|
||||||
|
_ => match self.content.get(&ino) {
|
||||||
|
Some(bytes) => {
|
||||||
|
let size = bytes.len();
|
||||||
|
reply.attr(&TTL, &self.file_attr(ino, size));
|
||||||
|
}
|
||||||
|
None => reply.error(libc::ENOENT),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(
|
||||||
|
&mut self,
|
||||||
|
_req: &Request<'_>,
|
||||||
|
ino: u64,
|
||||||
|
_fh: u64,
|
||||||
|
offset: i64,
|
||||||
|
size: u32,
|
||||||
|
_flags: i32,
|
||||||
|
_lock_owner: Option<u64>,
|
||||||
|
reply: ReplyData,
|
||||||
|
) {
|
||||||
|
let data: &[u8] = if ino == INO_README {
|
||||||
|
README.as_bytes()
|
||||||
|
} else {
|
||||||
|
match self.content.get(&ino) {
|
||||||
|
Some(bytes) => bytes.as_slice(),
|
||||||
|
None => {
|
||||||
|
reply.error(libc::ENOENT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let start = (offset.max(0) as usize).min(data.len());
|
||||||
|
let end = start.saturating_add(size as usize).min(data.len());
|
||||||
|
reply.data(&data[start..end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn readdir(
|
||||||
|
&mut self,
|
||||||
|
_req: &Request<'_>,
|
||||||
|
ino: u64,
|
||||||
|
_fh: u64,
|
||||||
|
offset: i64,
|
||||||
|
mut reply: ReplyDirectory,
|
||||||
|
) {
|
||||||
|
// Lista completa, incluidos `.` y `..`; el `offset` indica
|
||||||
|
// desde qué entrada reanudar.
|
||||||
|
let entries: Vec<(u64, FileType, String)> = match ino {
|
||||||
|
INO_ROOT => vec![
|
||||||
|
(INO_ROOT, FileType::Directory, ".".into()),
|
||||||
|
(INO_ROOT, FileType::Directory, "..".into()),
|
||||||
|
(INO_README, FileType::RegularFile, "README".into()),
|
||||||
|
(INO_ROOTS_DIR, FileType::Directory, "roots".into()),
|
||||||
|
(INO_CAS_DIR, FileType::Directory, "cas".into()),
|
||||||
|
],
|
||||||
|
INO_ROOTS_DIR => {
|
||||||
|
let mut v = vec![
|
||||||
|
(INO_ROOTS_DIR, FileType::Directory, ".".into()),
|
||||||
|
(INO_ROOT, FileType::Directory, "..".into()),
|
||||||
|
];
|
||||||
|
for hash in self.source.roots() {
|
||||||
|
let name = hash.to_string();
|
||||||
|
let child = self.intern_ino(INO_ROOTS_DIR, &name);
|
||||||
|
v.push((child, FileType::RegularFile, name));
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
// `cas/` no se enumera: resuelve sólo por `lookup` directo.
|
||||||
|
INO_CAS_DIR => vec![
|
||||||
|
(INO_CAS_DIR, FileType::Directory, ".".into()),
|
||||||
|
(INO_ROOT, FileType::Directory, "..".into()),
|
||||||
|
],
|
||||||
|
_ => {
|
||||||
|
reply.error(libc::ENOTDIR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (i, (e_ino, kind, name)) in entries.into_iter().enumerate().skip(offset as usize) {
|
||||||
|
// El offset del siguiente registro es `i + 1`.
|
||||||
|
if reply.add(e_ino, (i + 1) as i64, kind, name) {
|
||||||
|
break; // buffer del kernel lleno
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reply.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn statfs(&mut self, _req: &Request<'_>, _ino: u64, reply: ReplyStatfs) {
|
||||||
|
// blocks, bfree, bavail, files, ffree, bsize, namelen, frsize.
|
||||||
|
reply.statfs(0, 0, 0, 0, 0, 512, 255, 512);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsea un nombre de archivo como un `ContentHash`: exactamente 64
|
||||||
|
/// dígitos hex en minúsculas (el formato que produce `Display`).
|
||||||
|
fn parse_hash(name: &str) -> Option<ContentHash> {
|
||||||
|
if name.len() != 64 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
let raw = name.as_bytes();
|
||||||
|
for (i, slot) in bytes.iter_mut().enumerate() {
|
||||||
|
let hi = hex_val(raw[2 * i])?;
|
||||||
|
let lo = hex_val(raw[2 * i + 1])?;
|
||||||
|
*slot = (hi << 4) | lo;
|
||||||
|
}
|
||||||
|
Some(ContentHash(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_val(c: u8) -> Option<u8> {
|
||||||
|
match c {
|
||||||
|
b'0'..=b'9' => Some(c - b'0'),
|
||||||
|
b'a'..=b'f' => Some(c - b'a' + 10),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_hash_accepts_64_lowercase_hex() {
|
||||||
|
let h = parse_hash(&"ab".repeat(32)).expect("64 hex válidos");
|
||||||
|
assert_eq!(h.0, [0xab; 32]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_hash_rejects_bad_length_and_chars() {
|
||||||
|
assert!(parse_hash("abc").is_none());
|
||||||
|
assert!(parse_hash(&"AB".repeat(32)).is_none(), "mayúsculas no");
|
||||||
|
assert!(parse_hash(&"zz".repeat(32)).is_none(), "no-hex no");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_hash_roundtrips_display() {
|
||||||
|
let original = ContentHash([0x3f; 32]);
|
||||||
|
let back = parse_hash(&original.to_string()).expect("roundtrip");
|
||||||
|
assert_eq!(original, back);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,80 @@
|
|||||||
//! minga-vfs: proyección virtual del repositorio como filesystem (vía
|
//! `minga-vfs`: proyecta el repositorio de Minga —direccionado por
|
||||||
//! FUSE). Resuelve hashes a bloques de código bajo demanda. Pendiente.
|
//! contenido semántico— como un filesystem FUSE de **sólo lectura**.
|
||||||
|
//!
|
||||||
|
//! Minga guarda código como un grafo de `StoredNode`s identificados por
|
||||||
|
//! `ContentHash`; los archivos ingeridos son las raíces del MST. Este
|
||||||
|
//! crate convierte ese grafo en algo que cualquier herramienta Unix
|
||||||
|
//! (`ls`, `cat`, `grep`, un editor) puede recorrer, sin exponer `sled`
|
||||||
|
//! ni la API del store.
|
||||||
|
//!
|
||||||
|
//! ## Layout del filesystem
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! <punto-de-montaje>/
|
||||||
|
//! ├── README explicación del propio VFS
|
||||||
|
//! ├── roots/ un archivo por raíz del MST (cada archivo ingerido)
|
||||||
|
//! │ └── <hash64> código fuente reconstruido, formato normalizado
|
||||||
|
//! └── cas/ cualquier nodo del store, resuelto bajo demanda
|
||||||
|
//! └── <hash64> S-expression del subárbol con ese hash
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! `roots/` **se enumera** (`ls` lista todas las raíces). `cas/` no se
|
||||||
|
//! enumera —son potencialmente decenas de miles de nodos— pero
|
||||||
|
//! `cas/<hash>` resuelve cualquier hash conocido: ése es el "blob por
|
||||||
|
//! hash bajo demanda". El mismo hash bajo `roots/` y bajo `cas/` da dos
|
||||||
|
//! vistas del mismo nodo: fuente reconstruida vs. árbol literal.
|
||||||
|
//!
|
||||||
|
//! ## Arquitectura (separabilidad)
|
||||||
|
//!
|
||||||
|
//! - [`render`] — `SemanticNode` → texto. Lógica pura, sin IO ni FUSE;
|
||||||
|
//! reutilizable por un frontend web o TUI.
|
||||||
|
//! - [`source`] — el contrato [`NodeSource`] y sus backends (`sled`
|
||||||
|
//! vía [`RepoSource`], memoria vía [`MemSource`]).
|
||||||
|
//! - `fs` — el único módulo acoplado a `fuser`: traduce el contrato a
|
||||||
|
//! la `Filesystem` trait.
|
||||||
|
|
||||||
|
mod fs;
|
||||||
|
pub mod render;
|
||||||
|
pub mod source;
|
||||||
|
|
||||||
|
pub use fs::MingaFs;
|
||||||
|
pub use source::{reconstruct, MemSource, NodeSource, RepoSource};
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use fuser::MountOption;
|
||||||
|
|
||||||
|
/// Opciones de montaje comunes: sólo lectura, etiquetado como `minga`
|
||||||
|
/// para que aparezca legible en `mount` / `df`.
|
||||||
|
fn mount_options() -> Vec<MountOption> {
|
||||||
|
vec![
|
||||||
|
MountOption::RO,
|
||||||
|
MountOption::FSName("minga".to_string()),
|
||||||
|
MountOption::Subtype("minga".to_string()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Monta el VFS en `mountpoint` y **bloquea** hasta que se desmonte
|
||||||
|
/// (`fusermount -u <punto>`, `umount`, o una señal al proceso).
|
||||||
|
///
|
||||||
|
/// El punto de montaje debe ser un directorio existente. El filesystem
|
||||||
|
/// es de sólo lectura: toda escritura falla con `EROFS`/`EACCES`.
|
||||||
|
pub fn mount<S, P>(source: S, mountpoint: P) -> io::Result<()>
|
||||||
|
where
|
||||||
|
S: NodeSource,
|
||||||
|
P: AsRef<Path>,
|
||||||
|
{
|
||||||
|
fuser::mount2(MingaFs::new(source), mountpoint, &mount_options())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Como [`mount`] pero spawnea un hilo de fondo y retorna de inmediato.
|
||||||
|
/// La sesión queda viva mientras el `BackgroundSession` no se dropee;
|
||||||
|
/// dropearlo desmonta el filesystem.
|
||||||
|
pub fn spawn_mount<S, P>(source: S, mountpoint: P) -> io::Result<fuser::BackgroundSession>
|
||||||
|
where
|
||||||
|
S: NodeSource + Send + 'static,
|
||||||
|
P: AsRef<Path>,
|
||||||
|
{
|
||||||
|
fuser::spawn_mount2(MingaFs::new(source), mountpoint, &mount_options())
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
//! Renderizado de un `SemanticNode` a texto legible. Lógica **pura**:
|
||||||
|
//! sin IO, sin FUSE, sin `sled`. El VFS la usa para materializar el
|
||||||
|
//! contenido de cada archivo virtual bajo demanda, pero es reutilizable
|
||||||
|
//! por cualquier frontend (web, TUI).
|
||||||
|
//!
|
||||||
|
//! Dos vistas complementarias:
|
||||||
|
//!
|
||||||
|
//! - [`render_source`]: reconstrucción **canónica** del código fuente.
|
||||||
|
//! El AST semántico descartó whitespace y comentarios al ingerir
|
||||||
|
//! (son `extra` en tree-sitter), así que esto NO recupera el archivo
|
||||||
|
//! original byte-a-byte: es una forma *normalizada*, re-indentada por
|
||||||
|
//! estructura de llaves. Para lenguajes con bloques por llaves
|
||||||
|
//! (Rust/TS/JS/Go) sale legible; Python —cuya estructura vive en la
|
||||||
|
//! indentación, y la indentación es trivia— sale como una secuencia
|
||||||
|
//! de tokens en pocas líneas. Es esperado: el hash es de la
|
||||||
|
//! estructura, no del formato.
|
||||||
|
//!
|
||||||
|
//! - [`render_sexp`]: el árbol como S-expression indentada. Vista
|
||||||
|
//! exacta y sin pérdida de lo que el store guarda de verdad.
|
||||||
|
|
||||||
|
use minga_core::SemanticNode;
|
||||||
|
|
||||||
|
/// Reconstruye el código fuente de un subárbol en forma canónica.
|
||||||
|
///
|
||||||
|
/// Recolecta los tokens hoja en orden y los re-imprime con un
|
||||||
|
/// pretty-printer mínimo, consciente de llaves: indenta tras `{`,
|
||||||
|
/// desindenta antes de `}`, corta línea tras `;`. El resultado termina
|
||||||
|
/// siempre con exactamente un `\n`.
|
||||||
|
pub fn render_source(node: &SemanticNode) -> String {
|
||||||
|
let mut tokens = Vec::new();
|
||||||
|
collect_leaves(node, &mut tokens);
|
||||||
|
pretty_print(&tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recolecta el texto de los nodos hoja en orden de recorrido (DFS).
|
||||||
|
/// Sólo las hojas tienen `leaf_text`; los nodos internos se recurren.
|
||||||
|
fn collect_leaves(node: &SemanticNode, out: &mut Vec<String>) {
|
||||||
|
match &node.leaf_text {
|
||||||
|
Some(bytes) => {
|
||||||
|
let text = String::from_utf8_lossy(bytes);
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
out.push(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
for child in &node.children {
|
||||||
|
collect_leaves(child, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tokens que no quieren un espacio a su izquierda (puntuación que se
|
||||||
|
/// pega al token anterior). Incluye `(` y `[`: en una vista normalizada
|
||||||
|
/// se pegan al identificador previo (`main()`, `v[0]`) — el caso de
|
||||||
|
/// llamada/indexado es el dominante.
|
||||||
|
fn no_space_before(t: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
t,
|
||||||
|
")" | "]" | "," | ";" | "." | "::" | "?" | ":" | "!" | "(" | "["
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tokens tras los cuales no va espacio (abren un grupo o son prefijos
|
||||||
|
/// que se pegan al token siguiente).
|
||||||
|
fn no_space_after(t: &str) -> bool {
|
||||||
|
matches!(t, "(" | "[" | "." | "::" | "!" | "#")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ¿Hace falta un espacio entre `prev` y `cur` en una misma línea?
|
||||||
|
fn needs_space(cur: &str, prev: &str) -> bool {
|
||||||
|
!no_space_before(cur) && !no_space_after(prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_indent(out: &mut String, indent: usize) {
|
||||||
|
for _ in 0..indent {
|
||||||
|
out.push_str(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-imprime una secuencia de tokens con indentación por llaves.
|
||||||
|
fn pretty_print(tokens: &[String]) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut indent: usize = 0;
|
||||||
|
// ¿Hay ya contenido en la línea en curso?
|
||||||
|
let mut line_open = false;
|
||||||
|
|
||||||
|
for (i, tok) in tokens.iter().enumerate() {
|
||||||
|
let t = tok.as_str();
|
||||||
|
let next = tokens.get(i + 1).map(String::as_str);
|
||||||
|
match t {
|
||||||
|
"{" => {
|
||||||
|
if line_open {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
out.push('{');
|
||||||
|
indent += 1;
|
||||||
|
out.push('\n');
|
||||||
|
line_open = false;
|
||||||
|
}
|
||||||
|
"}" => {
|
||||||
|
indent = indent.saturating_sub(1);
|
||||||
|
if line_open {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
push_indent(&mut out, indent);
|
||||||
|
out.push('}');
|
||||||
|
line_open = true;
|
||||||
|
// `} else`, `},`, `};`, `})`, `}.` se quedan en línea;
|
||||||
|
// cualquier otra cosa abre línea nueva.
|
||||||
|
if !matches!(next, Some("else") | Some(",") | Some(";") | Some(")") | Some(".")) {
|
||||||
|
out.push('\n');
|
||||||
|
line_open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
";" => {
|
||||||
|
out.push(';');
|
||||||
|
out.push('\n');
|
||||||
|
line_open = false;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if !line_open {
|
||||||
|
push_indent(&mut out, indent);
|
||||||
|
line_open = true;
|
||||||
|
} else if let Some(prev) = tokens.get(i.wrapping_sub(1)).map(String::as_str) {
|
||||||
|
if i > 0 && needs_space(t, prev) {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final canónico: exactamente un newline.
|
||||||
|
while out.ends_with([' ', '\t', '\n']) {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renderiza el subárbol como S-expression indentada (2 espacios por
|
||||||
|
/// nivel). Cada nodo es `(kind ...)`; los nodos con `field_name` lo
|
||||||
|
/// prefijan como `field: (kind ...)`; las hojas llevan su texto entre
|
||||||
|
/// comillas. Es la representación literal de lo que hay en el store.
|
||||||
|
pub fn render_sexp(node: &SemanticNode) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
sexp_node(node, 0, &mut out);
|
||||||
|
out.push('\n');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sexp_node(node: &SemanticNode, depth: usize, out: &mut String) {
|
||||||
|
for _ in 0..depth {
|
||||||
|
out.push_str(" ");
|
||||||
|
}
|
||||||
|
// Convención tree-sitter: el nombre de campo va FUERA del paréntesis.
|
||||||
|
if let Some(field) = &node.field_name {
|
||||||
|
out.push_str(field);
|
||||||
|
out.push_str(": ");
|
||||||
|
}
|
||||||
|
out.push('(');
|
||||||
|
out.push_str(&node.kind);
|
||||||
|
|
||||||
|
match &node.leaf_text {
|
||||||
|
Some(bytes) => {
|
||||||
|
out.push(' ');
|
||||||
|
out.push('"');
|
||||||
|
out.push_str(&escape(&String::from_utf8_lossy(bytes)));
|
||||||
|
out.push('"');
|
||||||
|
out.push(')');
|
||||||
|
}
|
||||||
|
None if node.children.is_empty() => {
|
||||||
|
out.push(')');
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
for child in &node.children {
|
||||||
|
out.push('\n');
|
||||||
|
sexp_node(child, depth + 1, out);
|
||||||
|
}
|
||||||
|
out.push(')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escapa una cadena para que quepa entre comillas en la S-expression.
|
||||||
|
fn escape(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
'\r' => out.push_str("\\r"),
|
||||||
|
'\t' => out.push_str("\\t"),
|
||||||
|
_ => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn branch(kind: &str, children: Vec<SemanticNode>) -> SemanticNode {
|
||||||
|
SemanticNode {
|
||||||
|
kind: kind.to_string(),
|
||||||
|
field_name: None,
|
||||||
|
leaf_text: None,
|
||||||
|
children,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_indents_on_braces() {
|
||||||
|
// tokens: fn main ( ) { let x = 1 ; }
|
||||||
|
let tree = branch(
|
||||||
|
"fn_item",
|
||||||
|
vec![
|
||||||
|
leaf("fn", "fn"),
|
||||||
|
leaf("ident", "main"),
|
||||||
|
leaf("(", "("),
|
||||||
|
leaf(")", ")"),
|
||||||
|
leaf("{", "{"),
|
||||||
|
leaf("let", "let"),
|
||||||
|
leaf("ident", "x"),
|
||||||
|
leaf("=", "="),
|
||||||
|
leaf("int", "1"),
|
||||||
|
leaf(";", ";"),
|
||||||
|
leaf("}", "}"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let out = render_source(&tree);
|
||||||
|
assert!(out.contains("fn main()"), "tokens pegados a paréntesis: {out:?}");
|
||||||
|
assert!(out.contains(" let x = 1;"), "cuerpo indentado: {out:?}");
|
||||||
|
assert!(out.ends_with("}\n"), "termina en una sola llave + newline: {out:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sexp_shows_kinds_fields_and_leaves() {
|
||||||
|
let mut id = leaf("identifier", "x");
|
||||||
|
id.field_name = Some("name".to_string());
|
||||||
|
let tree = branch("declaration", vec![id]);
|
||||||
|
let out = render_sexp(&tree);
|
||||||
|
assert!(out.contains("(declaration"));
|
||||||
|
assert!(out.contains("name: (identifier \"x\")"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sexp_escapes_special_chars() {
|
||||||
|
let out = render_sexp(&leaf("string", "a\"b\nc"));
|
||||||
|
assert!(out.contains("\\\""), "comilla escapada: {out:?}");
|
||||||
|
assert!(out.contains("\\n"), "newline escapado: {out:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//! Cobertura de la capa agnóstica del VFS (render + source) sobre
|
||||||
|
//! código real parseado con tree-sitter. No monta FUSE: verifica que
|
||||||
|
//! la proyección hash → contenido es correcta de punta a punta.
|
||||||
|
|
||||||
|
use minga_core::parse;
|
||||||
|
use minga_vfs::render::{render_sexp, render_source};
|
||||||
|
use minga_vfs::source::{reconstruct, MemSource, NodeSource};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ingest_rust_then_reconstruct_is_lossless_at_the_ast_level() {
|
||||||
|
let original = parse::rust("fn add(a: i32, b: i32) -> i32 { a + b }").unwrap();
|
||||||
|
|
||||||
|
let mut src = MemSource::new();
|
||||||
|
let hash = src.add_root(&original);
|
||||||
|
|
||||||
|
// El árbol reconstruido desde el store debe ser idéntico bit a bit
|
||||||
|
// al que se ingirió: el direccionamiento por contenido lo garantiza.
|
||||||
|
let back = reconstruct(&src, &hash).expect("el hash recién insertado resuelve");
|
||||||
|
assert_eq!(back, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roots_lists_every_ingested_file() {
|
||||||
|
let mut src = MemSource::new();
|
||||||
|
let a = src.add_root(&parse::rust("fn a() {}").unwrap());
|
||||||
|
let b = src.add_root(&parse::python("def b():\n pass\n").unwrap());
|
||||||
|
|
||||||
|
let roots = src.roots();
|
||||||
|
assert_eq!(roots.len(), 2);
|
||||||
|
assert!(roots.contains(&a));
|
||||||
|
assert!(roots.contains(&b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_view_recovers_rust_keywords_and_structure() {
|
||||||
|
let node = parse::rust("fn main() { let x = 1; }").unwrap();
|
||||||
|
let mut src = MemSource::new();
|
||||||
|
let hash = src.add_root(&node);
|
||||||
|
|
||||||
|
let rebuilt = reconstruct(&src, &hash).unwrap();
|
||||||
|
let text = render_source(&rebuilt);
|
||||||
|
|
||||||
|
for token in ["fn", "main", "let", "x", "1"] {
|
||||||
|
assert!(text.contains(token), "falta `{token}` en:\n{text}");
|
||||||
|
}
|
||||||
|
// El cuerpo entre llaves debe quedar indentado en su propia línea.
|
||||||
|
assert!(text.contains("\n "), "cuerpo sin indentar:\n{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sexp_view_exposes_tree_sitter_node_kinds() {
|
||||||
|
let node = parse::rust("fn main() {}").unwrap();
|
||||||
|
let mut src = MemSource::new();
|
||||||
|
let hash = src.add_root(&node);
|
||||||
|
|
||||||
|
let rebuilt = reconstruct(&src, &hash).unwrap();
|
||||||
|
let sexp = render_sexp(&rebuilt);
|
||||||
|
|
||||||
|
assert!(sexp.contains("(source_file"), "raíz del árbol:\n{sexp}");
|
||||||
|
assert!(sexp.contains("function_item"), "el ítem función:\n{sexp}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deduplicated_subtrees_share_one_node() {
|
||||||
|
// Dos archivos con la misma función `helper` deben compartir el
|
||||||
|
// subárbol en el store: ingerir el segundo no lo vuelve a guardar.
|
||||||
|
let mut src = MemSource::new();
|
||||||
|
let one = parse::rust("fn helper() { 42 }").unwrap();
|
||||||
|
let two = parse::rust("fn helper() { 42 }").unwrap();
|
||||||
|
|
||||||
|
let h1 = src.add_root(&one);
|
||||||
|
let h2 = src.add_root(&two);
|
||||||
|
|
||||||
|
// Estructura idéntica ⇒ mismo hash ⇒ una sola raíz.
|
||||||
|
assert_eq!(h1, h2);
|
||||||
|
assert_eq!(src.roots().len(), 1);
|
||||||
|
}
|
||||||
@@ -1,5 +1,57 @@
|
|||||||
# Changelog — minga (semantic_dht)
|
# Changelog — minga (semantic_dht)
|
||||||
|
|
||||||
|
### feat(minga-vfs): VFS FUSE — el repo como filesystem de sólo lectura
|
||||||
|
`minga-vfs` deja de ser un stub de 2 LOC: ahora monta el repositorio
|
||||||
|
direccionado por contenido como un filesystem FUSE navegable con
|
||||||
|
`ls`/`cat`/`grep`/un editor, sin exponer `sled`.
|
||||||
|
|
||||||
|
Layout del montaje:
|
||||||
|
- `README` — explicación del propio VFS.
|
||||||
|
- `roots/<hash>` — un archivo por raíz del MST (cada archivo
|
||||||
|
ingerido), con el código fuente **reconstruido** en formato
|
||||||
|
normalizado. `ls roots/` las enumera todas.
|
||||||
|
- `cas/<hash>` — la S-expression del subárbol de cualquier hash.
|
||||||
|
Este directorio NO se enumera (decenas de miles de nodos) pero
|
||||||
|
`cat cas/<hash>` resuelve cualquier hash conocido: es el "blob
|
||||||
|
por hash bajo demanda" que la SDD pedía.
|
||||||
|
|
||||||
|
Arquitectura, separando por capas (ver feedback de separabilidad):
|
||||||
|
- **`render`** — `SemanticNode` → texto. Lógica pura, sin IO ni
|
||||||
|
FUSE. `render_source` re-imprime los tokens hoja con un
|
||||||
|
pretty-printer mínimo consciente de llaves (indenta tras `{`,
|
||||||
|
corta tras `;`); `render_sexp` vuelca el árbol literal. El render
|
||||||
|
de fuente es normalizado, no byte-exacto: el AST descartó
|
||||||
|
whitespace y comentarios al ingerir. Python, cuya estructura vive
|
||||||
|
en la indentación, sale como secuencia de tokens — esperado.
|
||||||
|
- **`source`** — el contrato `NodeSource` (`roots()` + `get()`) y
|
||||||
|
`reconstruct()`. Backends: `RepoSource` sobre el `PersistentRepo`
|
||||||
|
de `sled`, `MemSource` en RAM (tests / índices efímeros).
|
||||||
|
- **`fs`** — único módulo acoplado a `fuser`: implementa la
|
||||||
|
`Filesystem` trait. Inodos estáticos reservados (1-4), dinámicos
|
||||||
|
desde 16 con tabla `(padre, nombre) → inodo` estable. Cada
|
||||||
|
archivo se renderiza y cachea en el primer `lookup`.
|
||||||
|
|
||||||
|
Dep nueva (workspace + minga-vfs):
|
||||||
|
- `fuser = "0.15"` con `default-features = false`: prescinde de
|
||||||
|
`pkg-config`/`libfuse-dev` en build; el montaje pasa a Rust puro
|
||||||
|
(helper `fusermount3` en runtime).
|
||||||
|
|
||||||
|
CLI: nuevo subcomando `minga mount <punto>` (`cmd_mount`), que abre
|
||||||
|
el repo, lo envuelve en `RepoSource` y bloquea hasta
|
||||||
|
`fusermount -u <punto>`. Pide la passphrase igual que `status`.
|
||||||
|
|
||||||
|
Tests: **14** nuevos en minga-vfs (9 unit: render con llaves,
|
||||||
|
S-expression con campos/escape, parseo de hash de 64 hex, roundtrip
|
||||||
|
de `MemSource`; 5 integration: ingest→reconstruct lossless a nivel
|
||||||
|
AST, `roots/` lista todo, vistas source/sexp, deduplicación de
|
||||||
|
subárboles). Los 10 tests de minga-cli intactos (sin regresión).
|
||||||
|
|
||||||
|
Pendientes:
|
||||||
|
- `cas/` cachea contenido sin tope: navegar un repo gigante crece
|
||||||
|
en RAM. Un LRU sería el siguiente paso si molesta.
|
||||||
|
- Sin extensión en los nombres (`roots/<hash>`): no guardamos el
|
||||||
|
lenguaje original, así que el editor no autodetecta sintaxis.
|
||||||
|
|
||||||
### feat(minga-explorer): listings de items recientes en cada stat card
|
### feat(minga-explorer): listings de items recientes en cada stat card
|
||||||
Iter 12. Hasta ahora minga-explorer mostraba sólo counts (3
|
Iter 12. Hasta ahora minga-explorer mostraba sólo counts (3
|
||||||
números). Ahora cada stat card muestra también un sample de los
|
números). Ahora cada stat card muestra también un sample de los
|
||||||
|
|||||||
Reference in New Issue
Block a user