⎇ c6a131ac259b8809d1d65e463a9203e6493decb4
crates/ente-brain/src/loader.rs
Historia
Quién tocó qué
crudo
1//! Loader de Cards y Reglas desde archivos JSON.
2//!
3//! Sustituye al antiguo `kcl_loader.rs` (eliminado): la rama KCL invocaba
4//! un subprocess al CLI Go `kcl` que ningún target real tenía instalado y
5//! cuya validación duplicaba `EntityCard::validate()`. La fuente de verdad
6//! del shape de la Card es Rust + serde; en disco se guarda JSON crudo.
7//!
8//! Ergonomía de autoría futura (RON, Dhall, etc.) se añade como ramas
9//! adicionales aquí cuando duela escribir JSON a mano. Hoy: una sola rama.
10
11use crate::rules::Rule;
12use ente_card::EntityCard;
13use std::path::Path;
14use tracing::info;
15
16/// Carga una `EntityCard` desde un archivo JSON. Pasa por
17/// `EntityCard::validate()` antes de devolver — falla rápida.
18pub fn load_card_file(path: &Path) -> anyhow::Result<EntityCard> {
19 info!(path = %path.display(), "cargando Card desde JSON");
20 let raw = std::fs::read_to_string(path)?;
21 let card = extract_card_from_json(&raw)?;
22 card.validate()
23 .map_err(|e| anyhow::anyhow!("Card inválida ({}): {e}", path.display()))?;
24 Ok(card)
25}
26
27/// Extrae una `EntityCard` de JSON. Acepta:
28/// 1. Object directamente serializable como EntityCard.
29/// 2. Object dict con un único valor que sea EntityCard (compat con
30/// salidas de generadores que envuelven en `{"seed": {...}}`).
31pub fn extract_card_from_json(raw: &str) -> anyhow::Result<EntityCard> {
32 let v: serde_json::Value = serde_json::from_str(raw)?;
33 let direct_err = match serde_json::from_value::<EntityCard>(v.clone()) {
34 Ok(c) => return Ok(c),
35 Err(e) => e,
36 };
37 if let serde_json::Value::Object(map) = v {
38 for (_, vv) in map {
39 if let Ok(c) = serde_json::from_value::<EntityCard>(vv) {
40 return Ok(c);
41 }
42 }
43 }
44 // Propagamos el error del intento directo: es el caso típico (JSON top-level
45 // = EntityCard) y su mensaje apunta al campo concreto que rompió.
46 anyhow::bail!("JSON no contiene una EntityCard válida: {direct_err}")
47}
48
49/// Carga reglas desde un archivo JSON.
50pub fn load_rules_file(path: &Path) -> anyhow::Result<Vec<Rule>> {
51 info!(path = %path.display(), "cargando reglas desde JSON");
52 let raw = std::fs::read_to_string(path)?;
53 extract_rules_from_json(&raw)
54}
55
56/// Extrae un `Vec<Rule>` de un blob de texto. Acepta tres formas:
57/// 1. JSONL: una `Rule` por línea (el formato que escribe `append_rule_jsonl`).
58/// 2. Array directo: `[{...}, {...}]`.
59/// 3. Object con un campo array: `{"rules": [...]}`.
60///
61/// Heurística: si el primer carácter no-blanco es `[` o `{` con formato
62/// "objeto-con-array", parseamos como JSON único; en otro caso intentamos
63/// línea-por-línea. Líneas vacías o que empiecen con `#` se ignoran (compat
64/// con archivos editados a mano que dejen comentarios estilo shell).
65pub fn extract_rules_from_json(raw: &str) -> anyhow::Result<Vec<Rule>> {
66 let trimmed_start = raw.trim_start();
67 let looks_jsonl = trimmed_start.starts_with('{')
68 && raw.lines().filter(|l| {
69 let t = l.trim();
70 !t.is_empty() && !t.starts_with('#')
71 }).count() > 1;
72
73 if !looks_jsonl {
74 // Camino clásico: un único documento JSON (array o objeto).
75 if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw) {
76 let arr = match v {
77 serde_json::Value::Array(_) => v,
78 serde_json::Value::Object(map) => map
79 .into_values()
80 .find(|x| x.is_array())
81 .ok_or_else(|| anyhow::anyhow!("JSON no contiene ningún array"))?,
82 _ => anyhow::bail!("JSON debe ser array o object con campo array"),
83 };
84 return Ok(serde_json::from_value(arr)?);
85 }
86 // Caer a JSONL si el documento único no parsea — útil para archivos
87 // que mezclan comentarios `#` (no JSON válido como documento único).
88 }
89
90 let mut rules = Vec::new();
91 for (idx, line) in raw.lines().enumerate() {
92 let t = line.trim();
93 if t.is_empty() || t.starts_with('#') { continue; }
94 let rule: Rule = serde_json::from_str(t)
95 .map_err(|e| anyhow::anyhow!("JSONL línea {}: {e}", idx + 1))?;
96 rules.push(rule);
97 }
98 Ok(rules)
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use crate::introspect::append_rule_jsonl;
105 use crate::rules::{Action, EventKind, EventPattern, LogLevel, Rule, Scope};
106 use ulid::Ulid;
107
108 fn sample_rule() -> Rule {
109 Rule {
110 id: Ulid::new(),
111 priority: 5,
112 when: EventPattern::Single { kind: EventKind::EnteSpawned },
113 then: vec![Action::Log {
114 level: LogLevel::Info,
115 message: "test".into(),
116 }],
117 scope: Scope::default(),
118 }
119 }
120
121 #[test]
122 fn rules_from_array() {
123 let r = sample_rule();
124 let raw = format!("[{}]", serde_json::to_string(&r).unwrap());
125 let parsed = extract_rules_from_json(&raw).expect("array parse");
126 assert_eq!(parsed.len(), 1);
127 }
128
129 #[test]
130 fn rules_from_object_with_array() {
131 let r = sample_rule();
132 let raw = format!(r#"{{"rules":[{}]}}"#, serde_json::to_string(&r).unwrap());
133 let parsed = extract_rules_from_json(&raw).expect("object parse");
134 assert_eq!(parsed.len(), 1);
135 }
136
137 #[test]
138 fn rules_from_jsonl_with_comments_and_blanks() {
139 let r1 = sample_rule();
140 let r2 = sample_rule();
141 let raw = format!(
142 "# header comment\n\n{}\n# inline comment\n{}\n\n",
143 serde_json::to_string(&r1).unwrap(),
144 serde_json::to_string(&r2).unwrap()
145 );
146 let parsed = extract_rules_from_json(&raw).expect("jsonl parse");
147 assert_eq!(parsed.len(), 2);
148 }
149
150 #[test]
151 fn append_rule_jsonl_roundtrip() {
152 let dir = tempdir_unique();
153 let path = dir.join("rules.jsonl");
154 let r1 = sample_rule();
155 let r2 = sample_rule();
156 append_rule_jsonl(&path, &r1).expect("append 1");
157 append_rule_jsonl(&path, &r2).expect("append 2");
158 let raw = std::fs::read_to_string(&path).expect("read back");
159 let parsed = extract_rules_from_json(&raw).expect("roundtrip parse");
160 assert_eq!(parsed.len(), 2);
161 assert_eq!(parsed[0].id, r1.id);
162 assert_eq!(parsed[1].id, r2.id);
163 let _ = std::fs::remove_dir_all(&dir);
164 }
165
166 fn tempdir_unique() -> std::path::PathBuf {
167 let base = std::env::temp_dir();
168 let p = base.join(format!("ente-brain-loader-{}", Ulid::new()));
169 std::fs::create_dir_all(&p).unwrap();
170 p
171 }
172}