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
101fn extract_transforms(json: SerdeValue) -> Vec<SerdeValue> {
105 match &json {
106 SerdeValue::Object(map) => {
107 if let Some(SerdeValue::Array(arr)) = map.get("transform") {
108 arr.clone()
109 } else if let Some(SerdeValue::Array(arr)) = map.get("transforms") {
110 arr.clone()
111 } else {
112 vec![json.clone()]
113 }
114 }
115 SerdeValue::Array(arr) => arr.clone(),
116 _ => vec![json.clone()],
117 }
118}
119
120pub fn parse_initial_payload(
127 payload_str: Option<&str>,
128 payload_file: Option<&Path>,
129) -> Result<JsonValue, CliError> {
130 let raw: Option<String> = match (payload_str, payload_file) {
131 (Some(s), _) => Some(s.to_string()),
132 (None, Some(path)) => Some(fs::read_to_string(path).map_err(|e| {
133 CliError::Other(format!(
134 "Failed to read payload file {}: {}",
135 path.display(),
136 e
137 ))
138 })?),
139 (None, None) => None,
140 };
141
142 match raw {
143 Some(s) => {
144 let json: SerdeValue =
145 serde_json::from_str(&s).map_err(|e| CliError::InvalidPayload(e.to_string()))?;
146 Ok(evorule_reactor::serde_to_tcb(&json))
147 }
148 None => Ok(JsonValue::empty_object()),
149 }
150}
151
152pub fn write_output(output: Option<&Path>, content: &str) -> Result<(), CliError> {
156 match output {
157 Some(path) => {
158 fs::write(path, content)?;
159 tracing::info!(path = %path.display(), "Output written");
160 }
161 None => println!("{}", content),
162 }
163 Ok(())
164}
165
166#[cfg(test)]
167mod tests {
168 #![allow(clippy::unwrap_used)]
169 use super::*;
170 use std::fs;
171 use std::io::Write;
172
173 fn make_temp_dir(name: &str) -> std::path::PathBuf {
174 let dir =
175 std::env::temp_dir().join(format!("evorule-cli-test-{}-{}", name, std::process::id()));
176 let _ = fs::remove_dir_all(&dir);
177 fs::create_dir_all(&dir).unwrap();
178 dir
179 }
180
181 fn cleanup(dir: &std::path::Path) {
182 let _ = fs::remove_dir_all(dir);
183 }
184
185 #[test]
186 fn test_load_rules_deterministic_order() {
187 let dir = make_temp_dir("deterministic");
189 let write_rule = |name: &str, t: &str| {
190 let mut f = fs::File::create(dir.join(name)).unwrap();
191 f.write_all(t.as_bytes()).unwrap();
192 };
193 write_rule(
194 "03-third.json",
195 r#"{"transform":[{"type":"set","params":{}}]}"#,
196 );
197 write_rule("01-first.json", r#"{"transform":[{"type":"noop"}]}"#);
198 write_rule(
199 "02-second.json",
200 r#"{"transform":[{"type":"set","params":{}}]}"#,
201 );
202
203 let rules1 = load_rules(&dir).unwrap();
204 let rules2 = load_rules(&dir).unwrap();
205
206 assert_eq!(rules1.len(), 3, "should load 3 transforms from 3 files");
207 assert_eq!(
208 rules1, rules2,
209 "load_rules must be deterministic across calls"
210 );
211
212 let first = &rules1[0];
214 let type_str = first.get("type").and_then(|v| v.as_str()).unwrap();
215 assert_eq!(type_str, "noop");
216
217 cleanup(&dir);
218 }
219
220 #[test]
221 fn test_load_rules_three_formats() {
222 let dir = make_temp_dir("formats");
223 fs::write(dir.join("a.json"), r#"{"transform":[{"type":"noop"}]}"#).unwrap();
225 fs::write(dir.join("b.json"), r#"{"transforms":[{"type":"noop"}]}"#).unwrap();
227 fs::write(dir.join("c.json"), r#"[{"type":"noop"}]"#).unwrap();
229 fs::write(dir.join("d.json"), r#"{"type":"noop"}"#).unwrap();
231
232 let rules = load_rules(&dir).unwrap();
233 assert_eq!(rules.len(), 4, "should load 4 transforms from 4 formats");
234
235 cleanup(&dir);
236 }
237
238 #[test]
239 fn test_load_rules_ignores_payload_json() {
240 let dir = make_temp_dir("payload-skip");
241 fs::write(
243 dir.join("01-capture.json"),
244 r#"{"transform":[{"type":"noop"}]}"#,
245 )
246 .unwrap();
247 fs::write(dir.join("payload.json"), r#"{"request_id":"REQ-001"}"#).unwrap();
249
250 let rules = load_rules(&dir).unwrap();
251 assert_eq!(
252 rules.len(),
253 1,
254 "payload.json must not be loaded as a rule, got {} transforms",
255 rules.len()
256 );
257 assert_eq!(
258 rules[0].get("type").and_then(|v| v.as_str()),
259 Some("noop"),
260 "only the real rule should be loaded"
261 );
262
263 cleanup(&dir);
264 }
265
266 #[test]
267 fn test_load_rules_dir_not_found() {
268 let result = load_rules(std::path::Path::new("/nonexistent/path/xyz"));
269 assert!(matches!(result, Err(CliError::RulesDirNotFound(_))));
270 }
271
272 #[test]
273 fn test_load_rules_no_json() {
274 let dir = make_temp_dir("empty");
275 fs::write(dir.join("readme.txt"), "not a rule").unwrap();
276 let result = load_rules(&dir);
277 assert!(matches!(result, Err(CliError::NoRulesFound(_))));
278 cleanup(&dir);
279 }
280
281 #[test]
282 fn test_parse_initial_payload_default() {
283 let payload = parse_initial_payload(None, None).unwrap();
284 assert_eq!(payload, JsonValue::empty_object());
285 }
286
287 #[test]
288 fn test_parse_initial_payload_from_string() {
289 let payload = parse_initial_payload(Some(r#"{"x": 42}"#), None).unwrap();
290 assert_eq!(payload.get("x").and_then(|v| v.as_i64()), Some(42));
291 }
292
293 #[test]
294 fn test_parse_initial_payload_invalid() {
295 let result = parse_initial_payload(Some("not json"), None);
296 assert!(matches!(result, Err(CliError::InvalidPayload(_))));
297 }
298}