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