Skip to main content

evorule_cli/
io_util.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 EvoRule Project
3// This file is part of EvoRule, licensed under GNU Affero General Public License v3 or later.
4//! I/O 工具:规则加载 + payload 解析 + 文件读写
5//!
6//! # P0-2 修复:确定性加载
7//! `load_rules` 必须按文件名排序后加载,保证不同平台/文件系统返回顺序一致。
8//! 原实现依赖 `fs::read_dir` 的返回顺序(Windows NTFS 字典序、Linux ext4 hash 序),
9//! 导致同目录规则在不同平台执行结果不同。
10//!
11//! # 复用 evorule-reactor 序列化
12//! `serde_json::Value → evorule_tcb::JsonValue` 转换复用 `evorule_reactor::serde_to_tcb`,
13//! 保证与 tier1 reactor WAL、tier2 auditor 的序列化路径一致。
14
15use std::fs;
16use std::path::Path;
17
18use evorule_tcb::JsonValue;
19use serde_json::Value as SerdeValue;
20
21use crate::error::CliError;
22
23/// 加载规则目录,合并 transform 列表
24///
25/// # 确定性加载(P0-2 修复)
26/// 按 `file_name()` 字典序排序后加载,消除 `fs::read_dir` 顺序差异。
27///
28/// # 支持的文件格式
29/// 每个 `.json` 文件可以是以下三种格式之一:
30/// 1. `{"transform": [...]}` 或 `{"transforms": [...]}` — 提取数组
31/// 2. `[{...}, {...}]` — 顶层数组,每项是一条 transform
32/// 3. `{...}` — 单条 transform 对象
33///
34/// # 保留数据文件排除
35/// 文件名恰好为 `payload.json`(大小写不敏感)的文件被当作**初始输入数据**而非规则,
36/// 不参与加载。若用户在规则目录内放置初始 payload,其 `{}` 通常无 `type` 字段,
37/// 会被误当成规则并触发 `missing field: type`(见 `parse_initial_payload` 与教程约定)。
38///
39/// # 错误
40/// - `RulesDirNotFound`:目录不存在
41/// - `NoRulesFound`:目录中无 `.json` 文件
42/// - `Io`:读取文件失败
43/// - `Json`:JSON 解析失败
44pub fn load_rules(rules_dir: &Path) -> Result<Vec<JsonValue>, CliError> {
45    if !rules_dir.exists() {
46        return Err(CliError::RulesDirNotFound(rules_dir.display().to_string()));
47    }
48    if !rules_dir.is_dir() {
49        return Err(CliError::RulesDirNotFound(format!(
50            "Not a directory: {}",
51            rules_dir.display()
52        )));
53    }
54
55    // 收集 .json 文件并按文件名排序(P0-2 修复)
56    // 排除保留数据文件 `payload.json`(大小写不敏感):它通常无 `type` 字段,
57    // 若被当规则加载会触发 "missing field: type"(见 parse_initial_payload 与教程约定)。
58    let mut entries: Vec<_> = fs::read_dir(rules_dir)?
59        .filter_map(Result::ok)
60        .filter(|e| {
61            let is_json = e
62                .path()
63                .extension()
64                .and_then(|s| s.to_str())
65                .is_some_and(|ext| ext == "json");
66            let is_payload = e
67                .file_name()
68                .to_str()
69                .is_some_and(|name| name.eq_ignore_ascii_case("payload.json"));
70            is_json && !is_payload
71        })
72        .collect();
73    entries.sort_by_key(|e| e.file_name());
74
75    if entries.is_empty() {
76        return Err(CliError::NoRulesFound(rules_dir.display().to_string()));
77    }
78
79    let mut all_transforms: Vec<SerdeValue> = Vec::new();
80    for entry in &entries {
81        let path = entry.path();
82        let content = fs::read_to_string(&path)?;
83        let json: SerdeValue = serde_json::from_str(&content)
84            .map_err(|e| CliError::Other(format!("Invalid JSON in {}: {}", path.display(), e)))?;
85        all_transforms.extend(extract_transforms(json));
86    }
87
88    tracing::info!(
89        files = entries.len(),
90        transforms = all_transforms.len(),
91        "Rules loaded"
92    );
93
94    // 复用 evorule-reactor 序列化:serde_json::Value → tier0 JsonValue
95    Ok(all_transforms
96        .into_iter()
97        .map(|v| evorule_reactor::serde_to_tcb(&v))
98        .collect())
99}
100
101/// 加载单个规则文件(compile-evopack host 编译器用)
102///
103/// 与 [`load_rules`] 单文件侧同构:支持三种格式
104/// (`{"transform": [...]}` / `{"transforms": [...]}` / 顶层数组 / 单对象),
105/// 复用 [`extract_transforms`] 提取 + `serde_to_tcb` 转换,保证与规则目录
106/// 加载路径序列化语义一致。
107///
108/// # 错误
109/// - `Io`:读取文件失败
110/// - `Other`:JSON 解析失败(含文件路径上下文)
111pub fn load_rules_file(rules_file: &Path) -> Result<Vec<JsonValue>, CliError> {
112    let content = fs::read_to_string(rules_file)?;
113    let json: SerdeValue = serde_json::from_str(&content)
114        .map_err(|e| CliError::Other(format!("Invalid JSON in {}: {}", rules_file.display(), e)))?;
115    Ok(extract_transforms(json)
116        .iter()
117        .map(evorule_reactor::serde_to_tcb)
118        .collect())
119}
120
121/// 从单个 JSON 文件提取 transform 列表
122///
123/// 支持三格式:`{transform: [...]}` / `{transforms: [...]}` / 顶层数组 / 单对象
124fn extract_transforms(json: SerdeValue) -> Vec<SerdeValue> {
125    match &json {
126        SerdeValue::Object(map) => {
127            if let Some(SerdeValue::Array(arr)) = map.get("transform") {
128                arr.clone()
129            } else if let Some(SerdeValue::Array(arr)) = map.get("transforms") {
130                arr.clone()
131            } else {
132                vec![json.clone()]
133            }
134        }
135        SerdeValue::Array(arr) => arr.clone(),
136        _ => vec![json.clone()],
137    }
138}
139
140/// 解析初始 payload
141///
142/// 优先级:`--payload` 字符串 > `--payload-file` 文件 > 默认空对象 `{}`
143///
144/// # 错误
145/// - `InvalidPayload`:JSON 解析失败
146pub fn parse_initial_payload(
147    payload_str: Option<&str>,
148    payload_file: Option<&Path>,
149) -> Result<JsonValue, CliError> {
150    let raw: Option<String> = match (payload_str, payload_file) {
151        (Some(s), _) => Some(s.to_string()),
152        (None, Some(path)) => Some(fs::read_to_string(path).map_err(|e| {
153            CliError::Other(format!(
154                "Failed to read payload file {}: {}",
155                path.display(),
156                e
157            ))
158        })?),
159        (None, None) => None,
160    };
161
162    match raw {
163        Some(s) => {
164            let json: SerdeValue =
165                serde_json::from_str(&s).map_err(|e| CliError::InvalidPayload(e.to_string()))?;
166            Ok(evorule_reactor::serde_to_tcb(&json))
167        }
168        None => Ok(JsonValue::empty_object()),
169    }
170}
171
172/// 写输出到文件或 stdout
173///
174/// `output` 为 `None` 时打印到 stdout,为 `Some(path)` 时写入文件。
175pub fn write_output(output: Option<&Path>, content: &str) -> Result<(), CliError> {
176    match output {
177        Some(path) => {
178            fs::write(path, content)?;
179            tracing::info!(path = %path.display(), "Output written");
180        }
181        None => println!("{}", content),
182    }
183    Ok(())
184}
185
186#[cfg(test)]
187mod tests {
188    #![allow(clippy::unwrap_used)]
189    use super::*;
190    use std::fs;
191    use std::io::Write;
192
193    fn make_temp_dir(name: &str) -> std::path::PathBuf {
194        let dir =
195            std::env::temp_dir().join(format!("evorule-cli-test-{}-{}", name, std::process::id()));
196        let _ = fs::remove_dir_all(&dir);
197        fs::create_dir_all(&dir).unwrap();
198        dir
199    }
200
201    fn cleanup(dir: &std::path::Path) {
202        let _ = fs::remove_dir_all(dir);
203    }
204
205    #[test]
206    fn test_load_rules_deterministic_order() {
207        // 创建 3 个文件,文件名顺序与写入顺序故意不一致
208        let dir = make_temp_dir("deterministic");
209        let write_rule = |name: &str, t: &str| {
210            let mut f = fs::File::create(dir.join(name)).unwrap();
211            f.write_all(t.as_bytes()).unwrap();
212        };
213        write_rule(
214            "03-third.json",
215            r#"{"transform":[{"type":"set","params":{}}]}"#,
216        );
217        write_rule("01-first.json", r#"{"transform":[{"type":"noop"}]}"#);
218        write_rule(
219            "02-second.json",
220            r#"{"transform":[{"type":"set","params":{}}]}"#,
221        );
222
223        let rules1 = load_rules(&dir).unwrap();
224        let rules2 = load_rules(&dir).unwrap();
225
226        assert_eq!(rules1.len(), 3, "should load 3 transforms from 3 files");
227        assert_eq!(
228            rules1, rules2,
229            "load_rules must be deterministic across calls"
230        );
231
232        // 验证顺序:01-first 的 noop 应该在第一位
233        let first = &rules1[0];
234        let type_str = first.get("type").and_then(|v| v.as_str()).unwrap();
235        assert_eq!(type_str, "noop");
236
237        cleanup(&dir);
238    }
239
240    #[test]
241    fn test_load_rules_three_formats() {
242        let dir = make_temp_dir("formats");
243        // 格式1: {transform: [...]}
244        fs::write(dir.join("a.json"), r#"{"transform":[{"type":"noop"}]}"#).unwrap();
245        // 格式2: {transforms: [...]}
246        fs::write(dir.join("b.json"), r#"{"transforms":[{"type":"noop"}]}"#).unwrap();
247        // 格式3: 顶层数组
248        fs::write(dir.join("c.json"), r#"[{"type":"noop"}]"#).unwrap();
249        // 格式4: 单对象
250        fs::write(dir.join("d.json"), r#"{"type":"noop"}"#).unwrap();
251
252        let rules = load_rules(&dir).unwrap();
253        assert_eq!(rules.len(), 4, "should load 4 transforms from 4 formats");
254
255        cleanup(&dir);
256    }
257
258    #[test]
259    fn test_load_rules_ignores_payload_json() {
260        let dir = make_temp_dir("payload-skip");
261        // 规则文件
262        fs::write(
263            dir.join("01-capture.json"),
264            r#"{"transform":[{"type":"noop"}]}"#,
265        )
266        .unwrap();
267        // 保留数据文件:无 type 字段的初始 payload,不应被当作规则加载
268        fs::write(dir.join("payload.json"), r#"{"request_id":"REQ-001"}"#).unwrap();
269
270        let rules = load_rules(&dir).unwrap();
271        assert_eq!(
272            rules.len(),
273            1,
274            "payload.json must not be loaded as a rule, got {} transforms",
275            rules.len()
276        );
277        assert_eq!(
278            rules[0].get("type").and_then(|v| v.as_str()),
279            Some("noop"),
280            "only the real rule should be loaded"
281        );
282
283        cleanup(&dir);
284    }
285
286    #[test]
287    fn test_load_rules_dir_not_found() {
288        let result = load_rules(std::path::Path::new("/nonexistent/path/xyz"));
289        assert!(matches!(result, Err(CliError::RulesDirNotFound(_))));
290    }
291
292    #[test]
293    fn test_load_rules_no_json() {
294        let dir = make_temp_dir("empty");
295        fs::write(dir.join("readme.txt"), "not a rule").unwrap();
296        let result = load_rules(&dir);
297        assert!(matches!(result, Err(CliError::NoRulesFound(_))));
298        cleanup(&dir);
299    }
300
301    #[test]
302    fn test_load_rules_file_formats() {
303        // 与 load_rules 同构:单文件支持 transform/transforms/顶层数组/单对象四形态
304        let dir = make_temp_dir("single-file");
305        let write_and_load = |name: &str, content: &str| {
306            let path = dir.join(name);
307            fs::write(&path, content).unwrap();
308            load_rules_file(&path).unwrap()
309        };
310
311        let t = write_and_load("a.json", r#"{"transform":[{"type":"noop"}]}"#);
312        assert_eq!(t.len(), 1);
313        let t = write_and_load("b.json", r#"{"transforms":[{"type":"noop"}]}"#);
314        assert_eq!(t.len(), 1);
315        let t = write_and_load("c.json", r#"[{"type":"noop"},{"type":"noop"}]"#);
316        assert_eq!(t.len(), 2);
317        let t = write_and_load("d.json", r#"{"type":"noop"}"#);
318        assert_eq!(t.len(), 1);
319
320        cleanup(&dir);
321    }
322
323    #[test]
324    fn test_load_rules_file_invalid_json() {
325        let dir = make_temp_dir("single-file-bad");
326        let path = dir.join("bad.json");
327        fs::write(&path, "not json").unwrap();
328        let result = load_rules_file(&path);
329        assert!(
330            matches!(&result, Err(CliError::Other(msg)) if msg.contains("Invalid JSON")),
331            "got: {result:?}"
332        );
333        cleanup(&dir);
334    }
335
336    #[test]
337    fn test_load_rules_file_not_found() {
338        let result = load_rules_file(std::path::Path::new("/nonexistent/xyz.json"));
339        assert!(matches!(result, Err(CliError::Io(_))));
340    }
341
342    #[test]
343    fn test_parse_initial_payload_default() {
344        let payload = parse_initial_payload(None, None).unwrap();
345        assert_eq!(payload, JsonValue::empty_object());
346    }
347
348    #[test]
349    fn test_parse_initial_payload_from_string() {
350        let payload = parse_initial_payload(Some(r#"{"x": 42}"#), None).unwrap();
351        assert_eq!(payload.get("x").and_then(|v| v.as_i64()), Some(42));
352    }
353
354    #[test]
355    fn test_parse_initial_payload_invalid() {
356        let result = parse_initial_payload(Some("not json"), None);
357        assert!(matches!(result, Err(CliError::InvalidPayload(_))));
358    }
359}