1use std::ffi::c_char;
14use std::ptr;
15
16use pamoja_kit::Edge;
17use pamoja_profile::{Fired, RuleEvaluator, Rules};
18use serde_json::{json, Value};
19
20use crate::{read_str, set_last_error, PamojaStatus, PamojaString};
21
22pub struct PamojaRuleEvaluator {
27 inner: RuleEvaluator,
28}
29
30#[no_mangle]
47pub unsafe extern "C" fn pamoja_rule_evaluator_from_json(
48 text: *const c_char,
49) -> *mut PamojaRuleEvaluator {
50 let Some(text) = read_str(text, "text") else {
51 return ptr::null_mut();
52 };
53 match Rules::from_json(text).and_then(RuleEvaluator::new) {
54 Ok(inner) => Box::into_raw(Box::new(PamojaRuleEvaluator { inner })),
55 Err(error) => {
56 set_last_error(error.to_string());
57 ptr::null_mut()
58 }
59 }
60}
61
62#[no_mangle]
77pub unsafe extern "C" fn pamoja_rule_evaluator_to_json(
78 rules: *const PamojaRuleEvaluator,
79) -> *mut PamojaString {
80 let Some(rules) = handle(rules) else {
81 return ptr::null_mut();
82 };
83 let file = Rules {
84 rules: rules.inner.rules().cloned().collect(),
85 };
86 match file.to_json() {
87 Ok(text) => PamojaString::into_raw(text),
88 Err(error) => {
89 set_last_error(error.to_string());
90 ptr::null_mut()
91 }
92 }
93}
94
95#[no_mangle]
117pub unsafe extern "C" fn pamoja_rule_evaluator_evaluate(
118 rules: *mut PamojaRuleEvaluator,
119 topic: *const c_char,
120 reading: f32,
121) -> *mut PamojaString {
122 let Some(topic) = read_str(topic, "topic") else {
123 return ptr::null_mut();
124 };
125 if rules.is_null() {
126 set_last_error("rules must not be null".to_owned());
127 return ptr::null_mut();
128 }
129 match (*rules).inner.evaluate(topic, reading) {
130 Ok(fired) => {
131 let list: Vec<Value> = fired.iter().map(fired_json).collect();
132 PamojaString::into_raw(Value::Array(list).to_string())
133 }
134 Err(error) => {
135 set_last_error(error.to_string());
136 ptr::null_mut()
137 }
138 }
139}
140
141#[no_mangle]
160pub unsafe extern "C" fn pamoja_rule_evaluator_watches(
161 rules: *const PamojaRuleEvaluator,
162 topic: *const c_char,
163) -> bool {
164 match (handle(rules), read_str(topic, "topic")) {
165 (Some(rules), Some(topic)) => rules.inner.watches(topic),
166 _ => false,
167 }
168}
169
170#[no_mangle]
185pub unsafe extern "C" fn pamoja_rule_evaluator_topics_json(
186 rules: *const PamojaRuleEvaluator,
187) -> *mut PamojaString {
188 match handle(rules) {
189 Some(rules) => PamojaString::into_raw(json!(rules.inner.topics()).to_string()),
190 None => ptr::null_mut(),
191 }
192}
193
194#[no_mangle]
210pub unsafe extern "C" fn pamoja_rule_evaluator_actuators_json(
211 rules: *const PamojaRuleEvaluator,
212) -> *mut PamojaString {
213 match handle(rules) {
214 Some(rules) => PamojaString::into_raw(json!(rules.inner.actuators()).to_string()),
215 None => ptr::null_mut(),
216 }
217}
218
219#[no_mangle]
238pub unsafe extern "C" fn pamoja_rule_evaluator_is_set(
239 rules: *const PamojaRuleEvaluator,
240 rule: *const c_char,
241 out_set: *mut bool,
242) -> PamojaStatus {
243 if out_set.is_null() {
244 set_last_error("out_set must not be null".to_owned());
245 return PamojaStatus::InvalidArgument;
246 }
247 let (Some(rules), Some(name)) = (handle(rules), read_str(rule, "rule")) else {
248 return PamojaStatus::InvalidArgument;
249 };
250 match rules.inner.is_set(name) {
251 Some(set) => {
252 *out_set = set;
253 PamojaStatus::Ok
254 }
255 None => {
256 set_last_error(format!("no rule is named `{name}`"));
257 PamojaStatus::InvalidArgument
258 }
259 }
260}
261
262#[no_mangle]
271pub unsafe extern "C" fn pamoja_rule_evaluator_free(rules: *mut PamojaRuleEvaluator) {
272 if !rules.is_null() {
273 drop(Box::from_raw(rules));
274 }
275}
276
277fn fired_json(fired: &Fired) -> Value {
279 let edge = match fired.edge {
280 Edge::Set => "set",
281 Edge::Cleared => "cleared",
282 };
283 json!({
284 "rule": fired.rule,
285 "edge": edge,
286 "reading": fired.reading,
287 "actions": fired.actions,
288 })
289}
290
291unsafe fn handle<'a>(rules: *const PamojaRuleEvaluator) -> Option<&'a PamojaRuleEvaluator> {
297 if rules.is_null() {
298 set_last_error("rules must not be null".to_owned());
299 None
300 } else {
301 Some(&*rules)
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use std::ffi::{CStr, CString};
308
309 use super::*;
310 use crate::{pamoja_last_error_message, pamoja_string_data, pamoja_string_free};
311
312 const FILE: &str = r#"{ "rules": [ {
313 "name": "water-when-dry",
314 "when": { "topic": "garden/bed-1/moisture", "below": 30.0, "hysteresis": 5.0 },
315 "then": [ { "drive": "bed-valve", "on": true } ],
316 "otherwise": [ { "drive": "bed-valve", "on": false },
317 { "publish": "garden/bed-1/valve", "payload": "closed" } ]
318 } ] }"#;
319
320 unsafe fn take(text: *mut PamojaString) -> String {
322 assert!(!text.is_null(), "a string came back");
323 let read = CStr::from_ptr(pamoja_string_data(text))
324 .to_str()
325 .unwrap()
326 .to_owned();
327 pamoja_string_free(text);
328 read
329 }
330
331 fn last_error() -> String {
333 unsafe { CStr::from_ptr(pamoja_last_error_message()) }
335 .to_string_lossy()
336 .into_owned()
337 }
338
339 #[test]
340 fn a_rule_file_judges_readings_and_says_what_to_do() {
341 let file = CString::new(FILE).unwrap();
342 let topic = CString::new("garden/bed-1/moisture").unwrap();
343 let elsewhere = CString::new("garden/bed-2/moisture").unwrap();
344 let rule = CString::new("water-when-dry").unwrap();
345 unsafe {
347 let rules = pamoja_rule_evaluator_from_json(file.as_ptr());
348 assert!(!rules.is_null());
349 assert!(pamoja_rule_evaluator_watches(rules, topic.as_ptr()));
350 assert!(!pamoja_rule_evaluator_watches(rules, elsewhere.as_ptr()));
351 assert_eq!(
352 take(pamoja_rule_evaluator_topics_json(rules)),
353 r#"["garden/bed-1/moisture"]"#
354 );
355 assert_eq!(
356 take(pamoja_rule_evaluator_actuators_json(rules)),
357 r#"["bed-valve"]"#
358 );
359
360 assert_eq!(
361 take(pamoja_rule_evaluator_evaluate(rules, topic.as_ptr(), 31.0)),
362 "[]"
363 );
364 let dry: Value = serde_json::from_str(&take(pamoja_rule_evaluator_evaluate(
365 rules,
366 topic.as_ptr(),
367 28.0,
368 )))
369 .unwrap();
370 assert_eq!(dry[0]["rule"], "water-when-dry");
371 assert_eq!(dry[0]["edge"], "set");
372 assert_eq!(
373 dry[0]["actions"],
374 json!([{ "drive": "bed-valve", "on": true }])
375 );
376 let mut set = false;
377 assert_eq!(
378 pamoja_rule_evaluator_is_set(rules, rule.as_ptr(), &mut set),
379 PamojaStatus::Ok
380 );
381 assert!(set);
382
383 let wet: Value = serde_json::from_str(&take(pamoja_rule_evaluator_evaluate(
384 rules,
385 topic.as_ptr(),
386 36.0,
387 )))
388 .unwrap();
389 assert_eq!(wet[0]["edge"], "cleared");
390 assert_eq!(wet[0]["actions"][1]["payload"], "closed");
391
392 assert!(pamoja_rule_evaluator_evaluate(rules, topic.as_ptr(), f32::NAN).is_null());
393 assert!(last_error().contains("not a finite number"));
394
395 let written = take(pamoja_rule_evaluator_to_json(rules));
396 assert_eq!(
397 Rules::from_json(&written).unwrap(),
398 Rules::from_json(FILE).unwrap()
399 );
400 pamoja_rule_evaluator_free(rules);
401 }
402 }
403
404 #[test]
405 fn a_rule_file_no_engine_could_run_is_refused_with_the_reason() {
406 let filter =
407 CString::new(FILE.replace("garden/bed-1/moisture", "garden/+/moisture")).unwrap();
408 let rules = unsafe { pamoja_rule_evaluator_from_json(filter.as_ptr()) };
410 assert!(rules.is_null());
411 assert!(last_error().contains("a rule watches one topic exactly"));
412 }
413
414 #[test]
415 fn null_handles_are_tolerated() {
416 let rule = CString::new("water-when-dry").unwrap();
417 unsafe {
419 assert!(!pamoja_rule_evaluator_watches(ptr::null(), rule.as_ptr()));
420 assert!(pamoja_rule_evaluator_topics_json(ptr::null()).is_null());
421 let mut set = false;
422 assert_eq!(
423 pamoja_rule_evaluator_is_set(ptr::null(), rule.as_ptr(), &mut set),
424 PamojaStatus::InvalidArgument
425 );
426 pamoja_rule_evaluator_free(ptr::null_mut());
427 }
428 }
429}