use std::fs;
use std::path::Path;
use evorule_tcb::JsonValue;
use serde_json::Value as SerdeValue;
use crate::error::CliError;
pub fn load_rules(rules_dir: &Path) -> Result<Vec<JsonValue>, CliError> {
if !rules_dir.exists() {
return Err(CliError::RulesDirNotFound(rules_dir.display().to_string()));
}
if !rules_dir.is_dir() {
return Err(CliError::RulesDirNotFound(format!(
"Not a directory: {}",
rules_dir.display()
)));
}
let mut entries: Vec<_> = fs::read_dir(rules_dir)?
.filter_map(Result::ok)
.filter(|e| {
e.path()
.extension()
.and_then(|s| s.to_str())
.is_some_and(|ext| ext == "json")
})
.collect();
entries.sort_by_key(|e| e.file_name());
if entries.is_empty() {
return Err(CliError::NoRulesFound(rules_dir.display().to_string()));
}
let mut all_transforms: Vec<SerdeValue> = Vec::new();
for entry in &entries {
let path = entry.path();
let content = fs::read_to_string(&path)?;
let json: SerdeValue = serde_json::from_str(&content)
.map_err(|e| CliError::Other(format!("Invalid JSON in {}: {}", path.display(), e)))?;
all_transforms.extend(extract_transforms(json));
}
tracing::info!(
files = entries.len(),
transforms = all_transforms.len(),
"Rules loaded"
);
Ok(all_transforms
.into_iter()
.map(|v| evorule_reactor::serde_to_tcb(&v))
.collect())
}
fn extract_transforms(json: SerdeValue) -> Vec<SerdeValue> {
match &json {
SerdeValue::Object(map) => {
if let Some(SerdeValue::Array(arr)) = map.get("transform") {
arr.clone()
} else if let Some(SerdeValue::Array(arr)) = map.get("transforms") {
arr.clone()
} else {
vec![json.clone()]
}
}
SerdeValue::Array(arr) => arr.clone(),
_ => vec![json.clone()],
}
}
pub fn parse_initial_payload(
payload_str: Option<&str>,
payload_file: Option<&Path>,
) -> Result<JsonValue, CliError> {
let raw: Option<String> = match (payload_str, payload_file) {
(Some(s), _) => Some(s.to_string()),
(None, Some(path)) => Some(fs::read_to_string(path).map_err(|e| {
CliError::Other(format!(
"Failed to read payload file {}: {}",
path.display(),
e
))
})?),
(None, None) => None,
};
match raw {
Some(s) => {
let json: SerdeValue =
serde_json::from_str(&s).map_err(|e| CliError::InvalidPayload(e.to_string()))?;
Ok(evorule_reactor::serde_to_tcb(&json))
}
None => Ok(JsonValue::empty_object()),
}
}
pub fn write_output(output: Option<&Path>, content: &str) -> Result<(), CliError> {
match output {
Some(path) => {
fs::write(path, content)?;
tracing::info!(path = %path.display(), "Output written");
}
None => println!("{}", content),
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use std::fs;
use std::io::Write;
fn make_temp_dir(name: &str) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("evorule-cli-test-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn cleanup(dir: &std::path::Path) {
let _ = fs::remove_dir_all(dir);
}
#[test]
fn test_load_rules_deterministic_order() {
let dir = make_temp_dir("deterministic");
let write_rule = |name: &str, t: &str| {
let mut f = fs::File::create(dir.join(name)).unwrap();
f.write_all(t.as_bytes()).unwrap();
};
write_rule(
"03-third.json",
r#"{"transform":[{"type":"set","params":{}}]}"#,
);
write_rule("01-first.json", r#"{"transform":[{"type":"noop"}]}"#);
write_rule(
"02-second.json",
r#"{"transform":[{"type":"set","params":{}}]}"#,
);
let rules1 = load_rules(&dir).unwrap();
let rules2 = load_rules(&dir).unwrap();
assert_eq!(rules1.len(), 3, "should load 3 transforms from 3 files");
assert_eq!(
rules1, rules2,
"load_rules must be deterministic across calls"
);
let first = &rules1[0];
let type_str = first.get("type").and_then(|v| v.as_str()).unwrap();
assert_eq!(type_str, "noop");
cleanup(&dir);
}
#[test]
fn test_load_rules_three_formats() {
let dir = make_temp_dir("formats");
fs::write(dir.join("a.json"), r#"{"transform":[{"type":"noop"}]}"#).unwrap();
fs::write(dir.join("b.json"), r#"{"transforms":[{"type":"noop"}]}"#).unwrap();
fs::write(dir.join("c.json"), r#"[{"type":"noop"}]"#).unwrap();
fs::write(dir.join("d.json"), r#"{"type":"noop"}"#).unwrap();
let rules = load_rules(&dir).unwrap();
assert_eq!(rules.len(), 4, "should load 4 transforms from 4 formats");
cleanup(&dir);
}
#[test]
fn test_load_rules_dir_not_found() {
let result = load_rules(std::path::Path::new("/nonexistent/path/xyz"));
assert!(matches!(result, Err(CliError::RulesDirNotFound(_))));
}
#[test]
fn test_load_rules_no_json() {
let dir = make_temp_dir("empty");
fs::write(dir.join("readme.txt"), "not a rule").unwrap();
let result = load_rules(&dir);
assert!(matches!(result, Err(CliError::NoRulesFound(_))));
cleanup(&dir);
}
#[test]
fn test_parse_initial_payload_default() {
let payload = parse_initial_payload(None, None).unwrap();
assert_eq!(payload, JsonValue::empty_object());
}
#[test]
fn test_parse_initial_payload_from_string() {
let payload = parse_initial_payload(Some(r#"{"x": 42}"#), None).unwrap();
assert_eq!(payload.get("x").and_then(|v| v.as_i64()), Some(42));
}
#[test]
fn test_parse_initial_payload_invalid() {
let result = parse_initial_payload(Some("not json"), None);
assert!(matches!(result, Err(CliError::InvalidPayload(_))));
}
}