refactor(loader): A3 — unificar loader, eliminar duplicación
1//! Loader de Reglas (`Rule`) desde archivos JSON / JSONL.
2//!
3//! La carga de `Rule` vive aquí, junto a su definición. La carga de
4//! `EntityCard` se consolidó en `brahman-cards::entity_loader`.
5
6use crate::rules::Rule;
7use std::path::Path;
8use tracing::info;
9
10/// Carga reglas desde un archivo JSON / JSONL.
11pub fn load_rules_file(path: &Path) -> anyhow::Result<Vec<Rule>> {
12 info!(path = %path.display(), "cargando reglas desde JSON");
13 let raw = std::fs::read_to_string(path)?;
14 extract_rules_from_json(&raw)
15}
16
17/// Extrae un `Vec<Rule>` de un blob de texto. Acepta tres formas:
18/// 1. JSONL: una `Rule` por línea.
19/// 2. Array directo: `[{...}, {...}]`.
20/// 3. Object con un campo array: `{"rules": [...]}`.
21///
22/// Líneas vacías o que empiecen con `#` se ignoran (compat con archivos
23/// editados a mano que dejen comentarios estilo shell).
24pub fn extract_rules_from_json(raw: &str) -> anyhow::Result<Vec<Rule>> {
25 let trimmed_start = raw.trim_start();
26 let looks_jsonl = trimmed_start.starts_with('{')
27 && raw.lines().filter(|l| {
28 let t = l.trim();
29 !t.is_empty() && !t.starts_with('#')
30 }).count() > 1;
31
32 if !looks_jsonl {
33 // Camino clásico: un único documento JSON (array o objeto).
34 if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw) {
35 let arr = match v {
36 serde_json::Value::Array(_) => v,
37 serde_json::Value::Object(map) => map
38 .into_values()
39 .find(|x| x.is_array())
40 .ok_or_else(|| anyhow::anyhow!("JSON no contiene ningún array"))?,
41 _ => anyhow::bail!("JSON debe ser array o object con campo array"),
42 };
43 return Ok(serde_json::from_value(arr)?);
44 }
45 // Caer a JSONL si el documento único no parsea.
46 }
47
48 let mut rules = Vec::new();
49 for (idx, line) in raw.lines().enumerate() {
50 let t = line.trim();
51 if t.is_empty() || t.starts_with('#') { continue; }
52 let rule: Rule = serde_json::from_str(t)
53 .map_err(|e| anyhow::anyhow!("JSONL línea {}: {e}", idx + 1))?;
54 rules.push(rule);
55 }
56 Ok(rules)
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62 use crate::rules::{Action, EventKind, EventPattern, LogLevel, Rule, Scope};
63 use ulid::Ulid;
64
65 fn sample_rule() -> Rule {
66 Rule {
67 id: Ulid::new(),
68 priority: 5,
69 when: EventPattern::Single { kind: EventKind::EnteSpawned },
70 then: vec![Action::Log {
71 level: LogLevel::Info,
72 message: "test".into(),
73 }],
74 scope: Scope::default(),
75 }
76 }
77
78 #[test]
79 fn rules_from_array() {
80 let r = sample_rule();
81 let raw = format!("[{}]", serde_json::to_string(&r).unwrap());
82 let parsed = extract_rules_from_json(&raw).expect("array parse");
83 assert_eq!(parsed.len(), 1);
84 }
85
86 #[test]
87 fn rules_from_object_with_array() {
88 let r = sample_rule();
89 let raw = format!(r#"{{"rules":[{}]}}"#, serde_json::to_string(&r).unwrap());
90 let parsed = extract_rules_from_json(&raw).expect("object parse");
91 assert_eq!(parsed.len(), 1);
92 }
93
94 #[test]
95 fn rules_from_jsonl_with_comments_and_blanks() {
96 let r1 = sample_rule();
97 let r2 = sample_rule();
98 let raw = format!(
99 "# header comment\n\n{}\n# inline comment\n{}\n\n",
100 serde_json::to_string(&r1).unwrap(),
101 serde_json::to_string(&r2).unwrap()
102 );
103 let parsed = extract_rules_from_json(&raw).expect("jsonl parse");
104 assert_eq!(parsed.len(), 2);
105 }
106
107 #[test]
108 fn jsonl_roundtrip_preserves_order_and_ids() {
109 // Roundtrip JSONL escrito manualmente (una Rule por línea).
110 let r1 = sample_rule();
111 let r2 = sample_rule();
112 let raw = format!(
113 "{}\n{}\n",
114 serde_json::to_string(&r1).unwrap(),
115 serde_json::to_string(&r2).unwrap(),
116 );
117 let parsed = extract_rules_from_json(&raw).expect("roundtrip parse");
118 assert_eq!(parsed.len(), 2);
119 assert_eq!(parsed[0].id, r1.id);
120 assert_eq!(parsed[1].id, r2.id);
121 }
122}