Skip to main content

evorule_cli/
fact_log.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//! Fact log 读写(JSONL 格式)
5//!
6//! # 格式
7//! 每行一个 Fact 的 JSON 序列化,使用 `evorule_reactor::wal::fact_to_json`/`fact_from_json`
8//! 进行转换。格式与 tier1 reactor WAL 文件、tier2 auditor stable JSON 一致:
9//!
10//! ```json
11//! {"type":"Command","id":1,"instruction":{"type":"noop"}}
12//! {"type":"StateTransition","id":2,"cause":1,"new_payload":{},"new_queue":[]}
13//! {"type":"Stable","id":3,"version":1}
14//! ```
15//!
16//! # 设计决策
17//! 不使用 `FactsLog::with_wal`(那是为长驻反应器设计的 `Arc<RwLock>` + WAL writer),
18//! CLI 是 one-shot 执行,用 `Vec<Fact>` + 顺序写更简单。但序列化格式必须用 tier1 wal,
19//! 保证与 tier1 reactor WAL 文件格式互换,与 tier2 auditor 哈希链互通。
20//!
21//! # 不变量
22//! - `write_facts` 必须用 `evorule_reactor::wal::fact_to_json`,不能手写序列化
23//! - `read_facts` 必须用 `evorule_reactor::wal::fact_from_json`,不能手写反序列化
24//! - 这样 fact.log 可被 tier1 reactor `read_wal` 直接读取,反之亦然
25
26use std::fs;
27use std::path::Path;
28
29use evorule_reactor::{fact_from_json, fact_to_json, Fact};
30
31use crate::error::CliError;
32use crate::io_util::write_output;
33
34/// 将 Fact 列表写为 JSONL(每行一个 Fact)
35///
36/// `output` 为 `None` 时打印到 stdout,为 `Some(path)` 时写入文件。
37///
38/// # 序列化路径
39/// `Fact → evorule_reactor::wal::fact_to_json → serde_json::Value → serde_json::to_string`
40///
41/// 这样保证 fact.log 格式与 tier1 reactor WAL 文件格式互换。
42pub fn write_facts(output: Option<&Path>, facts: &[Fact]) -> Result<(), CliError> {
43    let lines: Vec<String> = facts
44        .iter()
45        .map(|f| {
46            let v = fact_to_json(f);
47            serde_json::to_string(&v).map_err(CliError::from)
48        })
49        .collect::<Result<_, _>>()?;
50
51    let content = lines.join("\n");
52    write_output(output, &content)
53}
54
55/// 从 JSONL 文件读取 Fact 列表
56///
57/// 每行一个 Fact 的 JSON 序列化。空行跳过,非 JSON 行报错。
58///
59/// # 反序列化路径
60/// `serde_json::from_str → serde_json::Value → evorule_reactor::wal::fact_from_json → Fact`
61///
62/// # 错误
63/// - `Io`:文件读取失败
64/// - `Json`:JSON 解析失败(行号通过 `FactLogParse` 携带)
65/// - `Wal`:`fact_from_json` 失败(字段缺失/类型不匹配/未知 fact type)
66/// - `FactLogParse`:行号 + 原因
67pub fn read_facts(path: &Path) -> Result<Vec<Fact>, CliError> {
68    let content = fs::read_to_string(path)?;
69    parse_facts(&content)
70}
71
72/// 解析 JSONL 字符串为 Fact 列表(内部函数,便于测试)
73fn parse_facts(content: &str) -> Result<Vec<Fact>, CliError> {
74    let mut facts = Vec::new();
75    for (idx, line) in content.lines().enumerate() {
76        let trimmed = line.trim();
77        if trimmed.is_empty() {
78            continue;
79        }
80        let v: serde_json::Value =
81            serde_json::from_str(trimmed).map_err(|e| CliError::FactLogParse {
82                line: idx + 1,
83                reason: format!("JSON parse: {}", e),
84            })?;
85        let fact = fact_from_json(&v).map_err(|e| CliError::FactLogParse {
86            line: idx + 1,
87            reason: format!("Fact deserialize: {}", e),
88        })?;
89        facts.push(fact);
90    }
91    Ok(facts)
92}
93
94#[cfg(test)]
95mod tests {
96    #![allow(clippy::unwrap_used, clippy::panic)]
97    use super::*;
98    use evorule_reactor::{Fact, FactId};
99    use evorule_tcb::JsonValue;
100
101    #[test]
102    fn test_write_read_roundtrip() {
103        let facts = vec![
104            Fact::Command {
105                id: FactId(1),
106                instruction: JsonValue::object_from_pairs(&[("type", JsonValue::string("noop"))]),
107            },
108            Fact::StateTransition {
109                id: FactId(2),
110                cause: FactId(1),
111                new_payload: JsonValue::empty_object(),
112                new_queue: vec![],
113            },
114            Fact::Stable {
115                id: FactId(3),
116                version: 1,
117            },
118        ];
119
120        // 写入临时文件
121        let tmp = std::env::temp_dir().join(format!(
122            "evorule-cli-factlog-roundtrip-{}.jsonl",
123            std::process::id()
124        ));
125        write_facts(Some(&tmp), &facts).unwrap();
126
127        // 读回验证
128        let read_back = read_facts(&tmp).unwrap();
129        assert_eq!(read_back.len(), facts.len());
130        assert_eq!(read_back, facts);
131
132        let _ = std::fs::remove_file(&tmp);
133    }
134
135    #[test]
136    fn test_write_to_stdout_does_not_panic() {
137        let facts = vec![Fact::Stable {
138            id: FactId(1),
139            version: 1,
140        }];
141        // stdout 写入应成功(None 路径)
142        let result = write_facts(None, &facts);
143        assert!(result.is_ok());
144    }
145
146    #[test]
147    fn test_read_facts_skips_empty_lines() {
148        // 旧格式(≤0.3.x 含 final_snapshot)字符串兼作向后兼容解析测试:
149        // fact_from_json 容错忽略 final_snapshot,version 兜底 0(CR-20260901-001)
150        let content = "{\"type\":\"Stable\",\"id\":1,\"final_snapshot\":{}}\n\n\n{\"type\":\"Stable\",\"id\":2,\"version\":1}\n";
151        let facts = parse_facts(content).unwrap();
152        assert_eq!(facts.len(), 2);
153    }
154
155    #[test]
156    fn test_read_facts_invalid_json_reports_line() {
157        let content = "{\"type\":\"Stable\",\"id\":1,\"version\":1}\nnot json at all\n";
158        let result = parse_facts(content);
159        match result {
160            Err(CliError::FactLogParse { line, .. }) => assert_eq!(line, 2),
161            other => panic!("expected FactLogParse at line 2, got {:?}", other),
162        }
163    }
164
165    #[test]
166    fn test_read_facts_unknown_fact_type_reports_line() {
167        let content = "{\"type\":\"UnknownVariant\",\"id\":1}\n";
168        let result = parse_facts(content);
169        match result {
170            Err(CliError::FactLogParse { line, reason }) => {
171                assert_eq!(line, 1);
172                assert!(reason.contains("unknown fact type"));
173            }
174            other => panic!("expected FactLogParse, got {:?}", other),
175        }
176    }
177
178    #[test]
179    fn test_fact_log_format_matches_tier1_wal() {
180        // 验证 fact.log 首行格式与 tier1 reactor WAL 一致
181        let fact = Fact::Command {
182            id: FactId(1),
183            instruction: JsonValue::object_from_pairs(&[("type", JsonValue::string("noop"))]),
184        };
185        let v = fact_to_json(&fact);
186        let line = serde_json::to_string(&v).unwrap();
187        assert!(
188            line.contains("\"type\":\"Command\""),
189            "fact log line should contain type discriminator, got: {}",
190            line
191        );
192        assert!(
193            line.contains("\"id\":1"),
194            "fact log line should contain id field, got: {}",
195            line
196        );
197    }
198}