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