ad_editor/
plumb.rs

1//! A plumbing interface for user defined "loading" of text inspired by plan 9's plumber.
2//!
3//! See the following papers and man pages for references on the original plan 9 system:
4//!   - <http://doc.cat-v.org/plan_9/4th_edition/papers/plumb>
5//!   - <http://man.cat-v.org/plan_9_3rd_ed/1/plumb>
6//!   - <http://man.cat-v.org/plan_9_3rd_ed/2/plumb>
7//!   - <http://man.cat-v.org/plan_9_3rd_ed/4/plumber>
8//!   - <http://man.cat-v.org/plan_9_3rd_ed/6/plumb>
9use crate::regex::Regex;
10use std::{
11    collections::BTreeMap,
12    env, fs, io,
13    process::{Command, Stdio},
14    str::FromStr,
15};
16use tracing::debug;
17
18/// An ordered list of plumbing rules to use whenever something is "loaded" within the editor.
19#[derive(Debug, Default, PartialEq, Eq)]
20pub struct PlumbingRules {
21    rules: Vec<Rule>,
22    vars: BTreeMap<String, String>,
23}
24
25impl FromStr for PlumbingRules {
26    type Err = String;
27
28    fn from_str(s: &str) -> Result<Self, Self::Err> {
29        let mut prs = Self::default();
30
31        for raw_block in s.split("\n\n") {
32            let lines: Vec<_> = raw_block
33                .trim()
34                .lines()
35                .filter(|l| !l.starts_with('#'))
36                .collect();
37
38            let mut block = lines.join("\n");
39            if block.is_empty() {
40                continue;
41            }
42
43            match block.split_once(' ') {
44                // Parse variable declaration blocks
45                Some((_, s)) if s.starts_with("=") => {
46                    for line in block.lines() {
47                        match line.split_once("=") {
48                            Some((var, val)) => {
49                                let mut k = non_empty_string(var.trim(), line)?;
50                                k.insert(0, '$');
51                                prs.vars.insert(k, non_empty_string(val.trim(), line)?);
52                            }
53                            _ => return Err(format!("malformed line: {line:?}")),
54                        }
55                    }
56                }
57
58                // Apply variables and parse rule blocks
59                _ => {
60                    for (k, v) in prs.vars.iter() {
61                        block = block.replace(k, v);
62                    }
63                    prs.rules.push(Rule::from_str(&block)?);
64                }
65            }
66        }
67
68        Ok(prs)
69    }
70}
71
72impl PlumbingRules {
73    /// Attempt to load plumbing rules from the default location
74    pub fn try_load() -> Result<Self, String> {
75        let home = env::var("HOME").unwrap();
76
77        Self::try_load_from_path(&format!("{home}/.ad/plumbing.rules"))
78    }
79
80    /// Attempt to load plumbing rules from a specified file
81    pub fn try_load_from_path(path: &str) -> Result<Self, String> {
82        let s = match fs::read_to_string(path) {
83            Ok(s) => s,
84            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
85            Err(e) => return Err(format!("Unable to load plumbing rules: {e}")),
86        };
87
88        match Self::from_str(&s) {
89            Ok(cfg) => Ok(cfg),
90            Err(e) => Err(format!("Invalid plumbing rules: {e}")),
91        }
92    }
93
94    /// Run the provided message through the plumbing rules to determine how it should be
95    /// handled. If no rules match then None is returned and default handling for a load
96    /// takes place instead. The returned message may differ from the one passed in if
97    /// rules carry out rewrites.
98    pub fn plumb(&mut self, msg: PlumbingMessage) -> Option<MatchOutcome> {
99        let vars = msg.initial_vars();
100        debug!("plumbing message: {msg:?}");
101
102        for (n, rule) in self.rules.iter_mut().enumerate() {
103            debug!("checking rule {n}");
104            let mut rule_vars = vars.clone();
105            if let Some(msg) = rule.try_match(msg.clone(), &mut rule_vars) {
106                debug!("rule matched");
107                return Some(msg);
108            }
109        }
110
111        debug!("no matching rules");
112        None
113    }
114}
115
116/// The deserialized form of a plumbing message sent by a client.
117#[derive(Debug, Default, PartialEq, Eq, Clone)]
118pub struct PlumbingMessage {
119    /// The application or service generating the message
120    pub src: Option<String>,
121    /// The destination "port" for the message
122    pub dst: Option<String>,
123    /// The working directory (used when data is a filename)
124    pub wdir: Option<String>,
125    /// The offset within 'data' where the user's cursor currently lies (default=0).
126    /// If a rule specifies a 'narrows-to' rule it will be used to narrow initial data
127    /// to only include the specified cursor, otherwise the match will be rejected.
128    pub cur: usize,
129    /// Name=value pairs. Must not contain newlines
130    pub attrs: BTreeMap<String, String>,
131    /// The string content of the message itself
132    pub data: String,
133}
134
135impl PlumbingMessage {
136    fn initial_vars(&self) -> BTreeMap<String, String> {
137        let mut vars = BTreeMap::new();
138        if let Some(s) = self.src.clone() {
139            vars.insert("$src".to_string(), s);
140        }
141        if let Some(s) = self.dst.clone() {
142            vars.insert("$dst".to_string(), s);
143        }
144        if let Some(s) = self.wdir.clone() {
145            vars.insert("$wdir".to_string(), s);
146        }
147        vars.insert("$data".to_string(), self.data.clone());
148
149        vars
150    }
151}
152
153macro_rules! parse_field {
154    ($line:expr, $field_name:expr, $prefix:expr, $field:expr) => {
155        match ($line.strip_prefix($prefix), &mut $field) {
156            (Some(val), None) => {
157                $field = Some(val.to_string());
158                continue;
159            }
160            (Some(_), Some(_)) => return Err(format!("duplicate {} field", $field_name)),
161            (None, _) => (),
162        }
163    };
164}
165
166fn parse_attr_list(s: &str) -> Result<BTreeMap<String, String>, String> {
167    let mut attrs = BTreeMap::new();
168    for pair in s.split(' ') {
169        match pair.split_once('=') {
170            Some((k, v)) => {
171                if k.is_empty() || v.is_empty() {
172                    return Err(format!("malformed attrs: {pair:?}"));
173                }
174                attrs.insert(k.to_string(), v.to_string());
175            }
176            None => return Err(format!("malformed attrs: {pair:?}")),
177        }
178    }
179
180    Ok(attrs)
181}
182
183impl FromStr for PlumbingMessage {
184    type Err = String;
185
186    fn from_str(s: &str) -> Result<Self, Self::Err> {
187        let mut msg = Self::default();
188        let mut it = s.lines();
189        let mut ndata = 0;
190        let mut cur_set = false;
191
192        for line in &mut it {
193            parse_field!(line, "src", "src: ", msg.src);
194            parse_field!(line, "dst", "dst: ", msg.dst);
195            parse_field!(line, "wdir", "wdir: ", msg.wdir);
196
197            match (line.strip_prefix("cur: "), &mut msg.cur) {
198                (Some(_), _) if cur_set => return Err("duplicate cur field".to_string()),
199                (Some(val), _) => {
200                    msg.cur = match val.parse() {
201                        Ok(cur) => cur,
202                        Err(e) => return Err(format!("malformed cur field: {e}")),
203                    };
204                    cur_set = true;
205                    continue;
206                }
207                (None, _) => (),
208            }
209
210            match (line.strip_prefix("attrs: "), msg.attrs.is_empty()) {
211                (Some(s), true) => {
212                    msg.attrs = parse_attr_list(s)?;
213                    continue;
214                }
215                (Some(_), false) => return Err("duplicate attrs field".to_string()),
216                (None, _) => (),
217            }
218
219            match line.strip_prefix("ndata: ") {
220                Some(n) => {
221                    ndata = match n.parse() {
222                        Ok(ndata) => ndata,
223                        Err(_) => return Err(format!("invalid ndata field {n:?}")),
224                    };
225                    break;
226                }
227                None => return Err(format!("malformed message: {line:?}")),
228            }
229        }
230
231        if ndata > 0 {
232            let stripped_lines: Vec<&str> = it.collect();
233            let joined = stripped_lines.join("\n");
234            match joined.strip_prefix("data: ") {
235                Some(data) => msg.data = data.to_string(),
236                None => return Err("malformed message: missing data field".to_string()),
237            }
238            if msg.data.len() != ndata {
239                return Err(format!(
240                    "malformed data. Expected {ndata} bytes but received {}: {:?}",
241                    msg.data.len(),
242                    msg.data
243                ));
244            }
245        }
246
247        Ok(msg)
248    }
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
252enum Pattern {
253    AddAttrs(BTreeMap<String, String>),
254    DelAttr(String),
255    DataFrom(String),
256    IsFile(String),
257    IsDir(String),
258    Is(Field, String),
259    Matches(Field, Regex),
260    NarrowsTo(Regex),
261    Set(Field, String),
262}
263
264impl Pattern {
265    fn kind_str(&self) -> &'static str {
266        match self {
267            Self::AddAttrs(_) => "add-attrs",
268            Self::DelAttr(_) => "del-attr",
269            Self::DataFrom(_) => "data-from",
270            Self::IsFile(_) => "is-file",
271            Self::IsDir(_) => "is-dir",
272            Self::Is(_, _) => "is",
273            Self::Matches(_, _) => "matches",
274            Self::NarrowsTo(_) => "narrows-to",
275            Self::Set(_, _) => "set",
276        }
277    }
278
279    fn match_and_update(
280        &mut self,
281        msg: &mut PlumbingMessage,
282        vars: &mut BTreeMap<String, String>,
283    ) -> bool {
284        let apply_vars = |mut s: String| {
285            for (k, v) in vars.iter() {
286                s = s.replace(k, v);
287            }
288            s
289        };
290
291        let re_match_and_update =
292            |f: Field, re: &mut Regex, vars: &mut BTreeMap<String, String>| {
293                debug!("regex match against {}", f.name());
294                let opt = match f {
295                    Field::Src => msg.src.as_ref(),
296                    Field::Dst => msg.dst.as_ref(),
297                    Field::Wdir => msg.wdir.as_ref(),
298                    Field::Data => Some(&msg.data),
299                };
300                let s = match opt {
301                    Some(s) => s,
302                    None => {
303                        debug!("unable to match against {} (not set in message)", f.name());
304                        return false;
305                    }
306                };
307
308                if let Some(m) = re.find(&s.as_str()) {
309                    debug!("matched: updating vars");
310                    vars.insert("$0".to_string(), m.match_text(&s.as_str()).into_owned());
311                    for n in 1..10 {
312                        match m.submatch_text(n, &s.as_str()) {
313                            Some(txt) => {
314                                vars.insert(format!("${n}"), txt.into_owned());
315                            }
316                            None => return true,
317                        }
318                    }
319                    return true;
320                }
321
322                debug!("message data did not match the provided regex");
323                false
324            };
325
326        debug!("checking {} pattern", self.kind_str());
327        match self {
328            Self::AddAttrs(attrs) => {
329                debug!("adding attrs: {attrs:?}");
330                msg.attrs.extend(
331                    attrs
332                        .clone()
333                        .into_iter()
334                        .map(|(k, v)| (apply_vars(k), apply_vars(v))),
335                );
336            }
337
338            Self::DelAttr(a) => {
339                debug!("removing attr: {a}");
340                msg.attrs.remove(a);
341            }
342
343            Self::IsFile(s) => {
344                debug!("checking if {s:?} is a file");
345                let path = apply_vars(s.clone());
346                match fs::metadata(&path) {
347                    Ok(m) => {
348                        if m.is_file() {
349                            debug!("{path:?} exists and is a file");
350                            vars.insert("$file".to_string(), path);
351                        } else {
352                            debug!("{path:?} exists but is not a file");
353                            return false;
354                        }
355                    }
356
357                    Err(e) => {
358                        debug!("unable to check {path:?}: {e}");
359                        return false;
360                    }
361                }
362            }
363
364            Self::IsDir(s) => {
365                debug!("checking if {s:?} is a directory");
366                let path = apply_vars(s.clone());
367                match fs::metadata(&path) {
368                    Ok(m) => {
369                        if m.is_dir() {
370                            debug!("{path:?} exists and is a directory");
371                            vars.insert("$dir".to_string(), path);
372                        } else {
373                            debug!("{path:?} exists but is not a directory");
374                            return false;
375                        }
376                    }
377
378                    Err(e) => {
379                        debug!("unable to check {path:?}: {e}");
380                        return false;
381                    }
382                }
383            }
384
385            Self::Is(Field::Src, s) => {
386                let res = msg.src.as_ref() == Some(s);
387                debug!("checking src == {s:?}: {res}");
388                return res;
389            }
390            Self::Is(Field::Dst, s) => {
391                let res = msg.dst.as_ref() == Some(s);
392                debug!("checking dst == {s:?}: {res}");
393                return res;
394            }
395            Self::Is(Field::Wdir, s) => {
396                let res = msg.wdir.as_ref() == Some(s);
397                debug!("checking wdir == {s:?}: {res}");
398                return res;
399            }
400            Self::Is(Field::Data, s) => {
401                let res = &msg.data == s;
402                debug!("checking data == {s:?}: {res}");
403                return res;
404            }
405
406            Self::Set(Field::Src, s) => {
407                let updated = apply_vars(s.clone());
408                debug!("setting src to {updated:?}");
409                msg.src = Some(updated.clone());
410                vars.insert("$src".to_string(), updated);
411            }
412            Self::Set(Field::Dst, s) => {
413                let updated = apply_vars(s.clone());
414                debug!("setting dst to {updated:?}");
415                msg.dst = Some(updated.clone());
416                vars.insert("$dst".to_string(), updated);
417            }
418            Self::Set(Field::Wdir, s) => {
419                let updated = apply_vars(s.clone());
420                debug!("setting wdir to {updated:?}");
421                msg.wdir = Some(updated.clone());
422                vars.insert("$wdir".to_string(), updated);
423            }
424            Self::Set(Field::Data, s) => {
425                let updated = apply_vars(s.clone());
426                debug!("setting data to {updated:?}");
427                msg.data = updated.clone();
428                vars.insert("$data".to_string(), updated);
429            }
430
431            Self::Matches(f, re) => return re_match_and_update(*f, re, vars),
432
433            Self::NarrowsTo(re) => {
434                debug!(%msg.cur, "narrowing for provided cur");
435                for m in re.find_iter(&msg.data.as_str()) {
436                    let (from, to) = m.loc();
437                    if from <= msg.cur && msg.cur <= to {
438                        debug!(%from, %to, "successfully narrowed");
439                        msg.data = m.match_text(&msg.data.as_str()).into_owned();
440                        msg.cur = 0; // consume the cursor as it is now invalid
441                        return true;
442                    }
443                }
444
445                return false; // unable to narrow to cur
446            }
447
448            Self::DataFrom(cmd) => {
449                debug!("running {cmd:?} to set message data");
450                let mut command = Command::new("sh");
451                command
452                    .args(["-c", apply_vars(cmd.clone()).as_str()])
453                    .stderr(Stdio::null());
454                let output = match command.output() {
455                    Ok(output) => output,
456                    Err(_) => return false,
457                };
458                msg.data = String::from_utf8(output.stdout).unwrap_or_default();
459            }
460        }
461
462        true
463    }
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
467enum Field {
468    Src,
469    Dst,
470    Wdir,
471    Data,
472}
473
474impl Field {
475    fn name(&self) -> &'static str {
476        match self {
477            Self::Src => "src",
478            Self::Dst => "dst",
479            Self::Wdir => "wdir",
480            Self::Data => "data",
481        }
482    }
483}
484
485impl FromStr for Field {
486    type Err = String;
487
488    fn from_str(s: &str) -> Result<Self, Self::Err> {
489        match s {
490            "src" => Ok(Self::Src),
491            "dst" => Ok(Self::Dst),
492            "wdir" => Ok(Self::Wdir),
493            "data" => Ok(Self::Data),
494            s => Err(format!("unknown field: {s:?}")),
495        }
496    }
497}
498
499#[derive(Debug, Clone, PartialEq, Eq)]
500enum Action {
501    To(String),
502    Start(String),
503}
504
505/// The result of a successful rule match that should be handled by ad.
506#[derive(Debug, Clone, PartialEq, Eq)]
507pub enum MatchOutcome {
508    /// A message that should be handled by ad
509    Message(PlumbingMessage),
510    /// A command that should be run instead of handling the message
511    Run(String),
512}
513
514/// A parsed plumbing rule for matching against incoming messages.
515/// If all patterns match then the resulting actions are run until
516/// one succeeds.
517#[derive(Debug, Default, Clone, PartialEq, Eq)]
518pub struct Rule {
519    patterns: Vec<Pattern>,
520    actions: Vec<Action>,
521}
522
523impl FromStr for Rule {
524    type Err = String;
525
526    fn from_str(s: &str) -> Result<Self, Self::Err> {
527        let mut rule = Self::default();
528
529        for line in s.lines() {
530            if line.starts_with('#') {
531                continue;
532            }
533
534            // actions and patterns that start with a fixed prefix
535            if let Some(s) = line.strip_prefix("attr add ") {
536                rule.patterns.push(Pattern::AddAttrs(parse_attr_list(s)?));
537            } else if let Some(s) = line.strip_prefix("attr delete ") {
538                rule.patterns
539                    .push(Pattern::DelAttr(non_empty_string(s, line)?));
540            } else if let Some(s) = line.strip_prefix("arg isfile ") {
541                rule.patterns
542                    .push(Pattern::IsFile(non_empty_string(s, line)?));
543            } else if let Some(s) = line.strip_prefix("arg isdir ") {
544                rule.patterns
545                    .push(Pattern::IsDir(non_empty_string(s, line)?));
546            } else if let Some(s) = line.strip_prefix("data from ") {
547                rule.patterns
548                    .push(Pattern::DataFrom(non_empty_string(s, line)?));
549            } else if let Some(s) = line.strip_prefix("plumb to ") {
550                rule.actions.push(Action::To(non_empty_string(s, line)?));
551            } else if let Some(s) = line.strip_prefix("plumb start ") {
552                rule.actions.push(Action::Start(non_empty_string(s, line)?));
553            } else if let Some(s) = line.strip_prefix("data narrows ") {
554                rule.patterns.push(Pattern::NarrowsTo(
555                    Regex::compile(s).map_err(|e| format!("malformed regex ({e:?}): {s}"))?,
556                ));
557            } else {
558                // patterns of the form $field $op $value
559                let (field, rest) = line
560                    .split_once(' ')
561                    .ok_or_else(|| format!("malformed rule line: {line}"))?;
562                let field = Field::from_str(field)?;
563                let (op, value) = rest
564                    .split_once(' ')
565                    .ok_or_else(|| format!("malformed rule line: {line}"))?;
566                let value = non_empty_string(value, line)?;
567
568                match op {
569                    "is" => rule.patterns.push(Pattern::Is(field, value)),
570                    "set" => rule.patterns.push(Pattern::Set(field, value)),
571                    "matches" => rule.patterns.push(Pattern::Matches(
572                        field,
573                        Regex::compile(&value)
574                            .map_err(|e| format!("malformed regex ({e:?}): {value}"))?,
575                    )),
576                    _ => return Err(format!("unknown rule operation: {op}")),
577                }
578            }
579        }
580
581        if rule.patterns.is_empty() {
582            Err("rule without patterns".to_string())
583        } else if rule.actions.is_empty() {
584            Err("rule without actions".to_string())
585        } else {
586            Ok(rule)
587        }
588    }
589}
590
591impl Rule {
592    fn try_match(
593        &mut self,
594        mut msg: PlumbingMessage,
595        vars: &mut BTreeMap<String, String>,
596    ) -> Option<MatchOutcome> {
597        for p in self.patterns.iter_mut() {
598            if !p.match_and_update(&mut msg, vars) {
599                debug!("pattern failed to match");
600                return None;
601            }
602        }
603
604        for a in self.actions.iter() {
605            match a {
606                // TODO: when other ports are supported they will need handling here!
607                Action::To(port) if port == "edit" => return Some(MatchOutcome::Message(msg)),
608                Action::Start(cmd) => {
609                    let mut s = cmd.clone();
610                    for (k, v) in vars.iter() {
611                        s = s.replace(k, v);
612                    }
613
614                    return Some(MatchOutcome::Run(s));
615                }
616                _ => continue,
617            }
618        }
619
620        None
621    }
622}
623
624fn non_empty_string(s: &str, line: &str) -> Result<String, String> {
625    if s.is_empty() {
626        Err(format!("malformed rule line: {line:?}"))
627    } else {
628        Ok(s.to_string())
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use simple_test_case::dir_cases;
636    use simple_txtar::Archive;
637
638    #[test]
639    fn parse_default_rules_works() {
640        let rules = PlumbingRules::from_str(include_str!("../data/plumbing.rules"));
641        assert!(rules.is_ok(), "{rules:?}");
642    }
643
644    #[test]
645    fn happy_path_plumb_works() {
646        let mut rules = PlumbingRules::from_str(include_str!("../data/plumbing.rules")).unwrap();
647        let m = PlumbingMessage {
648            data: "data/plumbing.rules:5:17:".to_string(),
649            ..Default::default()
650        };
651
652        let outcome = rules.plumb(m);
653        let m = match outcome {
654            Some(MatchOutcome::Message(m)) => m,
655            _ => panic!("expected message, got {outcome:?}"),
656        };
657
658        let expected = PlumbingMessage {
659            data: "data/plumbing.rules".to_string(),
660            attrs: [("addr".to_string(), "5:17".to_string())]
661                .into_iter()
662                .collect(),
663            ..Default::default()
664        };
665
666        assert_eq!(m, expected);
667    }
668
669    #[test]
670    fn parse_message_works() {
671        let m = PlumbingMessage::from_str(include_str!(
672            "../data/plumbing_tests/messages/valid/all-fields.txt"
673        ))
674        .unwrap();
675
676        let expected = PlumbingMessage {
677            src: Some("bash".to_string()),
678            dst: Some("ad".to_string()),
679            wdir: Some("/home/foo/bar".to_string()),
680            cur: 0,
681            attrs: [
682                ("a".to_string(), "b".to_string()),
683                ("c".to_string(), "d".to_string()),
684            ]
685            .into_iter()
686            .collect(),
687            data: "hello, world!".to_string(),
688        };
689
690        assert_eq!(m, expected);
691    }
692
693    #[dir_cases("data/plumbing_tests/messages/valid")]
694    #[test]
695    fn parse_valid_message(_: &str, content: &str) {
696        let res = PlumbingMessage::from_str(content);
697        assert!(res.is_ok(), "{res:?}");
698    }
699
700    #[dir_cases("data/plumbing_tests/messages/invalid")]
701    #[test]
702    fn parse_invalid_message(_: &str, content: &str) {
703        let res = PlumbingMessage::from_str(content);
704        assert!(res.is_err(), "{res:?}");
705    }
706
707    #[test]
708    fn parse_rule_works() {
709        let m = Rule::from_str(include_str!(
710            "../data/plumbing_tests/rules/valid/everything.txt"
711        ))
712        .unwrap();
713
714        let expected = Rule {
715            patterns: vec![
716                Pattern::AddAttrs([("a".to_string(), "b".to_string())].into_iter().collect()),
717                Pattern::DelAttr("c".to_string()),
718                Pattern::IsFile("$data".to_string()),
719                Pattern::IsDir("/var/lib".to_string()),
720                Pattern::Is(Field::Src, "ad".to_string()),
721                Pattern::Is(Field::Dst, "editor".to_string()),
722                Pattern::Set(Field::Wdir, "/foo/bar".to_string()),
723                Pattern::Matches(Field::Data, Regex::compile(r#"(.+):(\d+):(\d+):"#).unwrap()),
724                Pattern::Set(Field::Data, "$1:$2,$3".to_string()),
725            ],
726            actions: vec![
727                Action::To("editor".to_string()),
728                Action::Start("ad $file".to_string()),
729            ],
730        };
731
732        assert_eq!(m, expected);
733    }
734
735    #[dir_cases("data/plumbing_tests/rules/valid")]
736    #[test]
737    fn parse_valid_rule(_: &str, content: &str) {
738        let res = Rule::from_str(content);
739        assert!(res.is_ok(), "{res:?}");
740    }
741
742    #[dir_cases("data/plumbing_tests/rules/invalid")]
743    #[test]
744    fn parse_invalid_rule(_: &str, content: &str) {
745        let res = Rule::from_str(content);
746        assert!(res.is_err(), "{res:?}");
747    }
748
749    #[dir_cases("data/plumbing_tests/match-tests")]
750    #[test]
751    fn match_tests(_fname: &str, content: &str) {
752        let arr = Archive::from(content);
753        let comment = arr.comment();
754        if !comment.is_empty() {
755            println!("{comment}"); // help with debugging
756        }
757
758        let mut rules =
759            PlumbingRules::from_str(&arr.get("rules").expect("missing rules").content).unwrap();
760        let initial =
761            PlumbingMessage::from_str(&arr.get("initial").expect("missing initial").content)
762                .unwrap();
763
764        let raw_processed = &arr.get("processed").expect("missing processed").content;
765        let processed = if raw_processed.is_empty() {
766            None
767        } else {
768            Some(MatchOutcome::Message(
769                PlumbingMessage::from_str(raw_processed).unwrap(),
770            ))
771        };
772
773        let outcome = rules.plumb(initial);
774        assert_eq!(outcome, processed);
775    }
776}