//! Loader de Cards y Reglas desde archivos JSON. //! //! Sustituye al antiguo `kcl_loader.rs` (eliminado): la rama KCL invocaba //! un subprocess al CLI Go `kcl` que ningún target real tenía instalado y //! cuya validación duplicaba `EntityCard::validate()`. La fuente de verdad //! del shape de la Card es Rust + serde; en disco se guarda JSON crudo. //! //! Ergonomía de autoría futura (RON, Dhall, etc.) se añade como ramas //! adicionales aquí cuando duela escribir JSON a mano. Hoy: una sola rama. use crate::rules::Rule; use ente_card::EntityCard; use std::path::Path; use tracing::info; /// Carga una `EntityCard` desde un archivo JSON. Pasa por /// `EntityCard::validate()` antes de devolver — falla rápida. pub fn load_card_file(path: &Path) -> anyhow::Result { info!(path = %path.display(), "cargando Card desde JSON"); let raw = std::fs::read_to_string(path)?; let card = extract_card_from_json(&raw)?; card.validate() .map_err(|e| anyhow::anyhow!("Card inválida ({}): {e}", path.display()))?; Ok(card) } /// Extrae una `EntityCard` de JSON. Acepta: /// 1. Object directamente serializable como EntityCard. /// 2. Object dict con un único valor que sea EntityCard (compat con /// salidas de generadores que envuelven en `{"seed": {...}}`). pub fn extract_card_from_json(raw: &str) -> anyhow::Result { let v: serde_json::Value = serde_json::from_str(raw)?; let direct_err = match serde_json::from_value::(v.clone()) { Ok(c) => return Ok(c), Err(e) => e, }; if let serde_json::Value::Object(map) = v { for (_, vv) in map { if let Ok(c) = serde_json::from_value::(vv) { return Ok(c); } } } // Propagamos el error del intento directo: es el caso típico (JSON top-level // = EntityCard) y su mensaje apunta al campo concreto que rompió. anyhow::bail!("JSON no contiene una EntityCard válida: {direct_err}") } /// Carga reglas desde un archivo JSON. pub fn load_rules_file(path: &Path) -> anyhow::Result> { info!(path = %path.display(), "cargando reglas desde JSON"); let raw = std::fs::read_to_string(path)?; extract_rules_from_json(&raw) } /// Extrae un `Vec` de un blob de texto. Acepta tres formas: /// 1. JSONL: una `Rule` por línea (el formato que escribe `append_rule_jsonl`). /// 2. Array directo: `[{...}, {...}]`. /// 3. Object con un campo array: `{"rules": [...]}`. /// /// Heurística: si el primer carácter no-blanco es `[` o `{` con formato /// "objeto-con-array", parseamos como JSON único; en otro caso intentamos /// línea-por-línea. Líneas vacías o que empiecen con `#` se ignoran (compat /// con archivos editados a mano que dejen comentarios estilo shell). pub fn extract_rules_from_json(raw: &str) -> anyhow::Result> { let trimmed_start = raw.trim_start(); let looks_jsonl = trimmed_start.starts_with('{') && raw.lines().filter(|l| { let t = l.trim(); !t.is_empty() && !t.starts_with('#') }).count() > 1; if !looks_jsonl { // Camino clásico: un único documento JSON (array o objeto). if let Ok(v) = serde_json::from_str::(raw) { let arr = match v { serde_json::Value::Array(_) => v, serde_json::Value::Object(map) => map .into_values() .find(|x| x.is_array()) .ok_or_else(|| anyhow::anyhow!("JSON no contiene ningún array"))?, _ => anyhow::bail!("JSON debe ser array o object con campo array"), }; return Ok(serde_json::from_value(arr)?); } // Caer a JSONL si el documento único no parsea — útil para archivos // que mezclan comentarios `#` (no JSON válido como documento único). } let mut rules = Vec::new(); for (idx, line) in raw.lines().enumerate() { let t = line.trim(); if t.is_empty() || t.starts_with('#') { continue; } let rule: Rule = serde_json::from_str(t) .map_err(|e| anyhow::anyhow!("JSONL línea {}: {e}", idx + 1))?; rules.push(rule); } Ok(rules) } #[cfg(test)] mod tests { use super::*; use crate::introspect::append_rule_jsonl; use crate::rules::{Action, EventKind, EventPattern, LogLevel, Rule, Scope}; use ulid::Ulid; fn sample_rule() -> Rule { Rule { id: Ulid::new(), priority: 5, when: EventPattern::Single { kind: EventKind::EnteSpawned }, then: vec![Action::Log { level: LogLevel::Info, message: "test".into(), }], scope: Scope::default(), } } #[test] fn rules_from_array() { let r = sample_rule(); let raw = format!("[{}]", serde_json::to_string(&r).unwrap()); let parsed = extract_rules_from_json(&raw).expect("array parse"); assert_eq!(parsed.len(), 1); } #[test] fn rules_from_object_with_array() { let r = sample_rule(); let raw = format!(r#"{{"rules":[{}]}}"#, serde_json::to_string(&r).unwrap()); let parsed = extract_rules_from_json(&raw).expect("object parse"); assert_eq!(parsed.len(), 1); } #[test] fn rules_from_jsonl_with_comments_and_blanks() { let r1 = sample_rule(); let r2 = sample_rule(); let raw = format!( "# header comment\n\n{}\n# inline comment\n{}\n\n", serde_json::to_string(&r1).unwrap(), serde_json::to_string(&r2).unwrap() ); let parsed = extract_rules_from_json(&raw).expect("jsonl parse"); assert_eq!(parsed.len(), 2); } #[test] fn append_rule_jsonl_roundtrip() { let dir = tempdir_unique(); let path = dir.join("rules.jsonl"); let r1 = sample_rule(); let r2 = sample_rule(); append_rule_jsonl(&path, &r1).expect("append 1"); append_rule_jsonl(&path, &r2).expect("append 2"); let raw = std::fs::read_to_string(&path).expect("read back"); let parsed = extract_rules_from_json(&raw).expect("roundtrip parse"); assert_eq!(parsed.len(), 2); assert_eq!(parsed[0].id, r1.id); assert_eq!(parsed[1].id, r2.id); let _ = std::fs::remove_dir_all(&dir); } fn tempdir_unique() -> std::path::PathBuf { let base = std::env::temp_dir(); let p = base.join(format!("ente-brain-loader-{}", Ulid::new())); std::fs::create_dir_all(&p).unwrap(); p } }