tawasuyu
⎇ a4fa42c781d76852355931d82a28e80230855af8 crates/ente-brain/src/kcl_loader.rs Rust · 143 lines · 5.2 KB History Who touched what raw

Audit→CAS, reload rules, time-decay y forma canónica del hash chain a4fa42c78 · Sergio ·

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}