use std::fs;
use std::path::Path;
use evorule_reactor::{fact_from_json, fact_to_json, Fact};
use crate::error::CliError;
use crate::io_util::write_output;
pub fn write_facts(output: Option<&Path>, facts: &[Fact]) -> Result<(), CliError> {
let lines: Vec<String> = facts
.iter()
.map(|f| {
let v = fact_to_json(f);
serde_json::to_string(&v).map_err(CliError::from)
})
.collect::<Result<_, _>>()?;
let content = lines.join("\n");
write_output(output, &content)
}
pub fn read_facts(path: &Path) -> Result<Vec<Fact>, CliError> {
let content = fs::read_to_string(path)?;
parse_facts(&content)
}
fn parse_facts(content: &str) -> Result<Vec<Fact>, CliError> {
let mut facts = Vec::new();
for (idx, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let v: serde_json::Value =
serde_json::from_str(trimmed).map_err(|e| CliError::FactLogParse {
line: idx + 1,
reason: format!("JSON parse: {}", e),
})?;
let fact = fact_from_json(&v).map_err(|e| CliError::FactLogParse {
line: idx + 1,
reason: format!("Fact deserialize: {}", e),
})?;
facts.push(fact);
}
Ok(facts)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
use evorule_reactor::{Fact, FactId};
use evorule_tcb::JsonValue;
#[test]
fn test_write_read_roundtrip() {
let facts = vec![
Fact::Command {
id: FactId(1),
instruction: JsonValue::object_from_pairs(&[("type", JsonValue::string("noop"))]),
},
Fact::StateTransition {
id: FactId(2),
cause: FactId(1),
new_payload: JsonValue::empty_object(),
new_queue: vec![],
},
Fact::Stable {
id: FactId(3),
final_snapshot: JsonValue::empty_object(),
},
];
let tmp = std::env::temp_dir().join(format!(
"evorule-cli-factlog-roundtrip-{}.jsonl",
std::process::id()
));
write_facts(Some(&tmp), &facts).unwrap();
let read_back = read_facts(&tmp).unwrap();
assert_eq!(read_back.len(), facts.len());
assert_eq!(read_back, facts);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn test_write_to_stdout_does_not_panic() {
let facts = vec![Fact::Stable {
id: FactId(1),
final_snapshot: JsonValue::empty_object(),
}];
let result = write_facts(None, &facts);
assert!(result.is_ok());
}
#[test]
fn test_read_facts_skips_empty_lines() {
let content = "{\"type\":\"Stable\",\"id\":1,\"final_snapshot\":{}}\n\n\n{\"type\":\"Stable\",\"id\":2,\"final_snapshot\":{}}\n";
let facts = parse_facts(content).unwrap();
assert_eq!(facts.len(), 2);
}
#[test]
fn test_read_facts_invalid_json_reports_line() {
let content = "{\"type\":\"Stable\",\"id\":1,\"final_snapshot\":{}}\nnot json at all\n";
let result = parse_facts(content);
match result {
Err(CliError::FactLogParse { line, .. }) => assert_eq!(line, 2),
other => panic!("expected FactLogParse at line 2, got {:?}", other),
}
}
#[test]
fn test_read_facts_unknown_fact_type_reports_line() {
let content = "{\"type\":\"UnknownVariant\",\"id\":1}\n";
let result = parse_facts(content);
match result {
Err(CliError::FactLogParse { line, reason }) => {
assert_eq!(line, 1);
assert!(reason.contains("unknown fact type"));
}
other => panic!("expected FactLogParse, got {:?}", other),
}
}
#[test]
fn test_fact_log_format_matches_tier1_wal() {
let fact = Fact::Command {
id: FactId(1),
instruction: JsonValue::object_from_pairs(&[("type", JsonValue::string("noop"))]),
};
let v = fact_to_json(&fact);
let line = serde_json::to_string(&v).unwrap();
assert!(
line.contains("\"type\":\"Command\""),
"fact log line should contain type discriminator, got: {}",
line
);
assert!(
line.contains("\"id\":1"),
"fact log line should contain id field, got: {}",
line
);
}
}