1use std::fs;
16use std::path::Path;
17
18use evorule_tcb::JsonValue;
19use serde_json::Value as SerdeValue;
20
21use crate::error::CliError;
22
23pub 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 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 Ok(all_transforms
96 .into_iter()
97 .map(|v| evorule_reactor::serde_to_tcb(&v))
98 .collect())
99}
100
101pub 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
121fn 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
140pub 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
172pub 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 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 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 fs::write(dir.join("a.json"), r#"{"transform":[{"type":"noop"}]}"#).unwrap();
245 fs::write(dir.join("b.json"), r#"{"transforms":[{"type":"noop"}]}"#).unwrap();
247 fs::write(dir.join("c.json"), r#"[{"type":"noop"}]"#).unwrap();
249 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 fs::write(
263 dir.join("01-capture.json"),
264 r#"{"transform":[{"type":"noop"}]}"#,
265 )
266 .unwrap();
267 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 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}