Skip to main content

pamoja_ffi/
rules.rs

1//! The C ABI for rules between nodes.
2//!
3//! A rule file is judged here reading by reading, with no link: the host hands over the
4//! topic and the reading each message carried, and learns which rules set or cleared and
5//! what each calls for, which the host then carries out. That is the deciding half of the
6//! Rust `RuleEngine`, which owns a link and actuators and so cannot cross a C ABI.
7//!
8//! What fired crosses as JSON, an array of objects with the `rule` by name, the `edge`
9//! (`set` or `cleared`), the `reading`, and its `actions`, each written exactly as the
10//! rule file writes an action: `{ "drive": ..., "on": ... }` or
11//! `{ "publish": ..., "payload": ... }`.
12
13use 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
22/// An opaque handle to a set of rules armed to judge readings.
23///
24/// Create it with [`pamoja_rule_evaluator_from_json`] and release it with
25/// [`pamoja_rule_evaluator_free`].
26pub struct PamojaRuleEvaluator {
27    inner: RuleEvaluator,
28}
29
30/// Loads a rule file and arms its rules, every condition starting cleared.
31///
32/// # Arguments
33///
34/// * `text` - the rule file, as null-terminated UTF-8.
35///
36/// # Returns
37///
38/// A handle the caller must release with [`pamoja_rule_evaluator_free`], or null if the
39/// text is not a rule file or holds a rule no engine could run, with the reason
40/// available from [`pamoja_last_error_message`](crate::pamoja_last_error_message).
41///
42/// # Safety
43///
44/// `text` must be a valid null-terminated UTF-8 string for the duration of the call, or
45/// null.
46#[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/// Writes the rules back out as the file a fleet shares.
63///
64/// # Arguments
65///
66/// * `rules` - the evaluator.
67///
68/// # Returns
69///
70/// A string the caller must release with
71/// [`pamoja_string_free`](crate::pamoja_string_free), or null if `rules` is null.
72///
73/// # Safety
74///
75/// `rules` must be a live handle from [`pamoja_rule_evaluator_from_json`], or null.
76#[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/// Judges one reading from one topic against every rule that watches it.
96///
97/// # Arguments
98///
99/// * `rules` - the evaluator.
100/// * `topic` - the topic the reading arrived on, as null-terminated UTF-8.
101/// * `reading` - the reading, already decoded from the message.
102///
103/// # Returns
104///
105/// The JSON array of what fired, in the order the rules are listed, which is `[]` when no
106/// rule watches the topic or the reading changed nothing. The caller releases it with
107/// [`pamoja_string_free`](crate::pamoja_string_free). Null if a pointer is null, or if a
108/// rule watches the topic and the reading is not a finite number, with the reason
109/// available from [`pamoja_last_error_message`](crate::pamoja_last_error_message).
110///
111/// # Safety
112///
113/// `rules` must be a live handle from [`pamoja_rule_evaluator_from_json`], or null, and
114/// `topic` must be a valid null-terminated UTF-8 string for the duration of the call, or
115/// null.
116#[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/// Reports whether any rule watches a topic, so the host knows whether to decode a
142/// message before handing it over.
143///
144/// # Arguments
145///
146/// * `rules` - the evaluator.
147/// * `topic` - the topic a message arrived on, as null-terminated UTF-8.
148///
149/// # Returns
150///
151/// `true` when some rule watches the topic, and `false` otherwise or if a pointer is
152/// null.
153///
154/// # Safety
155///
156/// `rules` must be a live handle from [`pamoja_rule_evaluator_from_json`], or null, and
157/// `topic` must be a valid null-terminated UTF-8 string for the duration of the call, or
158/// null.
159#[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/// Lists the topics the rules watch, which are the topics to subscribe to.
171///
172/// # Arguments
173///
174/// * `rules` - the evaluator.
175///
176/// # Returns
177///
178/// A JSON array of the topics, each once, in name order, which the caller releases with
179/// [`pamoja_string_free`](crate::pamoja_string_free), or null if `rules` is null.
180///
181/// # Safety
182///
183/// `rules` must be a live handle from [`pamoja_rule_evaluator_from_json`], or null.
184#[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/// Lists the actuators the rules drive, which are the outputs the host has to have.
195///
196/// # Arguments
197///
198/// * `rules` - the evaluator.
199///
200/// # Returns
201///
202/// A JSON array of the actuator names, each once, in name order, which the caller
203/// releases with [`pamoja_string_free`](crate::pamoja_string_free), or null if `rules`
204/// is null.
205///
206/// # Safety
207///
208/// `rules` must be a live handle from [`pamoja_rule_evaluator_from_json`], or null.
209#[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/// Reports whether a rule's condition currently holds.
220///
221/// # Arguments
222///
223/// * `rules` - the evaluator.
224/// * `rule` - the rule's name, as null-terminated UTF-8.
225/// * `out_set` - receives whether the condition holds.
226///
227/// # Returns
228///
229/// [`PamojaStatus::Ok`] with `*out_set` written, or [`PamojaStatus::InvalidArgument`] if
230/// a pointer is null or no rule has that name.
231///
232/// # Safety
233///
234/// `rules` must be a live handle from [`pamoja_rule_evaluator_from_json`], or null,
235/// `rule` must be a valid null-terminated UTF-8 string for the duration of the call, or
236/// null, and `out_set` must be writable.
237#[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/// Releases an evaluator handle.
263///
264/// Passing null is a no-op.
265///
266/// # Safety
267///
268/// `rules` must be a handle from [`pamoja_rule_evaluator_from_json`] that has not already
269/// been freed, or null. After this call it must not be used again.
270#[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
277/// Writes what one rule did as the JSON object the host reads.
278fn 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
291/// Borrows the evaluator behind a handle, recording why when there is none.
292///
293/// # Safety
294///
295/// `rules` must be a live handle from [`pamoja_rule_evaluator_from_json`], or null.
296unsafe 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    /// Reads and releases a string the ABI handed back.
321    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    /// The message the last failed call left behind.
332    fn last_error() -> String {
333        // Safety: the message pointer is valid until the next failing call on this thread.
334        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        // Safety: the handle is created and released here and every string is valid.
346        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        // Safety: the string is valid for the call.
409        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        // Safety: every call below is documented to accept null.
418        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}