gioser
⎇ d6b8f18b43c9bd9bf7268e48b61392a2d62bb229 crates/ente-brain/src/kcl_loader.rs Rust · 135 líneas · 4.8 KB Historia Quién tocó qué crudo

Pausa: 11 crates del fractal Ente #0 con cerebro completo d6b8f18b4 · Sergio ·

1//! Loader de reglas desde archivos `.k` vía subprocess al CLI de KCL.
2//!
3//! No usamos el SDK Rust de KCL para no arrastrar la dependencia de Go runtime
4//! ni cgo. El CLI `kcl` produce JSON validado contra el schema declarado
5//! en el propio `.k` — equivalente funcional al SDK con coste cero de compile.
6//!
7//! Si `kcl` no está en PATH, el caller decide: cargar JSON crudo (skip KCL),
8//! o fallar el boot.
9//!
10//! ## Formato esperado del .k file
11//!
12//! ```kcl
13//! import .rule  # schema/rule.k
14//!
15//! rules: [Rule] = [
16//!     Rule { id = "...", priority = 5, when = ..., then = [...] },
17//!     ...
18//! ]
19//! ```
20//!
21//! Salida tras `kcl run --format json`: `{"rules": [...]}`. El loader busca
22//! la primera array en el JSON (top-level o anidada un nivel) y la deserializa.
23
24use crate::rules::Rule;
25use ente_card::EntityCard;
26use std::path::Path;
27use std::process::Command;
28use tracing::{debug, info};
29
30/// Detecta si `kcl` está disponible en PATH. Útil para degradar a JSON-only
31/// en entornos sin la toolchain.
32pub fn kcl_available() -> bool {
33    Command::new("kcl")
34        .arg("version")
35        .output()
36        .map(|o| o.status.success())
37        .unwrap_or(false)
38}
39
40/// Ejecuta `kcl run <path> --format=json` y devuelve el JSON crudo.
41pub fn run_kcl(path: &Path) -> anyhow::Result<String> {
42    let output = Command::new("kcl")
43        .arg("run")
44        .arg(path)
45        .arg("--format=json")
46        .output()
47        .map_err(|e| anyhow::anyhow!("invocando `kcl`: {e}. ¿Instalado en PATH?"))?;
48    if !output.status.success() {
49        anyhow::bail!(
50            "kcl run {} falló: {}",
51            path.display(),
52            String::from_utf8_lossy(&output.stderr)
53        );
54    }
55    debug!(path = %path.display(), out_bytes = output.stdout.len(), "kcl run ok");
56    Ok(String::from_utf8(output.stdout)?)
57}
58
59/// Carga reglas desde un archivo `.k` o JSON. Discrimina por extensión:
60/// `.k` → invoca KCL, `.json` → directo.
61pub fn load_rules_file(path: &Path) -> anyhow::Result<Vec<Rule>> {
62    let raw = match path.extension().and_then(|e| e.to_str()) {
63        Some("k") => {
64            info!(path = %path.display(), "cargando reglas vía kcl");
65            run_kcl(path)?
66        }
67        _ => {
68            info!(path = %path.display(), "cargando reglas como JSON crudo");
69            std::fs::read_to_string(path)?
70        }
71    };
72    extract_rules_from_json(&raw)
73}
74
75/// Extrae un `Vec<Rule>` de JSON que puede ser:
76/// 1. Array directo: `[{...}, {...}]`
77/// 2. Object con un campo array: `{"rules": [...]}`
78pub fn extract_rules_from_json(raw: &str) -> anyhow::Result<Vec<Rule>> {
79    let v: serde_json::Value = serde_json::from_str(raw)?;
80    let arr = match v {
81        serde_json::Value::Array(_) => v,
82        serde_json::Value::Object(map) => {
83            map.into_values()
84                .find(|x| x.is_array())
85                .ok_or_else(|| anyhow::anyhow!("JSON no contiene ningún array"))?
86        }
87        _ => anyhow::bail!("JSON debe ser array o object con campo array"),
88    };
89    let rules: Vec<Rule> = serde_json::from_value(arr)?;
90    Ok(rules)
91}
92
93// ============================================================================
94// Carga de Cards desde KCL/JSON. Cierra la "puerta genética": ninguna Card
95// se acepta sin pasar `validate()` extendido en ente-card.
96// ============================================================================
97
98/// Carga una `EntityCard` desde un archivo `.k` (vía kcl run) o `.json`.
99/// Pasa por `EntityCard::validate()` antes de devolver — falla rápida.
100pub fn load_card_file(path: &Path) -> anyhow::Result<EntityCard> {
101    let raw = match path.extension().and_then(|e| e.to_str()) {
102        Some("k") => {
103            info!(path = %path.display(), "cargando Card vía kcl");
104            run_kcl(path)?
105        }
106        _ => {
107            info!(path = %path.display(), "cargando Card como JSON crudo");
108            std::fs::read_to_string(path)?
109        }
110    };
111    let card = extract_card_from_json(&raw)?;
112    card.validate()
113        .map_err(|e| anyhow::anyhow!("Card inválida ({}): {e}", path.display()))?;
114    Ok(card)
115}
116
117/// Extrae una `EntityCard` de JSON. Acepta:
118/// 1. Object directamente serializable como EntityCard
119/// 2. Object dict con un único valor que sea EntityCard (KCL output típico)
120pub fn extract_card_from_json(raw: &str) -> anyhow::Result<EntityCard> {
121    let v: serde_json::Value = serde_json::from_str(raw)?;
122    // Intento 1: deserializar el value directamente.
123    if let Ok(c) = serde_json::from_value::<EntityCard>(v.clone()) {
124        return Ok(c);
125    }
126    // Intento 2: si es dict, buscar el primer value que parsee como Card.
127    if let serde_json::Value::Object(map) = v {
128        for (_, vv) in map {
129            if let Ok(c) = serde_json::from_value::<EntityCard>(vv) {
130                return Ok(c);
131            }
132        }
133    }
134    anyhow::bail!("JSON no contiene una EntityCard válida")
135}