⎇ d6b8f18b43c9bd9bf7268e48b61392a2d62bb229
crates/ente-brain/src/kcl_loader.rs
Historia
Quién tocó qué
crudo
Pausa: 11 crates del fractal Ente #0 con cerebro completo
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}