1use 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
34pub 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
55pub fn read_facts(path: &Path) -> Result<Vec<Fact>, CliError> {
68 let content = fs::read_to_string(path)?;
69 parse_facts(&content)
70}
71
72fn 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 final_snapshot: JsonValue::empty_object(),
117 },
118 ];
119
120 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 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 final_snapshot: JsonValue::empty_object(),
140 }];
141 let result = write_facts(None, &facts);
143 assert!(result.is_ok());
144 }
145
146 #[test]
147 fn test_read_facts_skips_empty_lines() {
148 let content = "{\"type\":\"Stable\",\"id\":1,\"final_snapshot\":{}}\n\n\n{\"type\":\"Stable\",\"id\":2,\"final_snapshot\":{}}\n";
149 let facts = parse_facts(content).unwrap();
150 assert_eq!(facts.len(), 2);
151 }
152
153 #[test]
154 fn test_read_facts_invalid_json_reports_line() {
155 let content = "{\"type\":\"Stable\",\"id\":1,\"final_snapshot\":{}}\nnot json at all\n";
156 let result = parse_facts(content);
157 match result {
158 Err(CliError::FactLogParse { line, .. }) => assert_eq!(line, 2),
159 other => panic!("expected FactLogParse at line 2, got {:?}", other),
160 }
161 }
162
163 #[test]
164 fn test_read_facts_unknown_fact_type_reports_line() {
165 let content = "{\"type\":\"UnknownVariant\",\"id\":1}\n";
166 let result = parse_facts(content);
167 match result {
168 Err(CliError::FactLogParse { line, reason }) => {
169 assert_eq!(line, 1);
170 assert!(reason.contains("unknown fact type"));
171 }
172 other => panic!("expected FactLogParse, got {:?}", other),
173 }
174 }
175
176 #[test]
177 fn test_fact_log_format_matches_tier1_wal() {
178 let fact = Fact::Command {
180 id: FactId(1),
181 instruction: JsonValue::object_from_pairs(&[("type", JsonValue::string("noop"))]),
182 };
183 let v = fact_to_json(&fact);
184 let line = serde_json::to_string(&v).unwrap();
185 assert!(
186 line.contains("\"type\":\"Command\""),
187 "fact log line should contain type discriminator, got: {}",
188 line
189 );
190 assert!(
191 line.contains("\"id\":1"),
192 "fact log line should contain id field, got: {}",
193 line
194 );
195 }
196}