1use 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#[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 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 _ => {
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 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 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 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#[derive(Debug, Default, PartialEq, Eq, Clone)]
118pub struct PlumbingMessage {
119 pub src: Option<String>,
121 pub dst: Option<String>,
123 pub wdir: Option<String>,
125 pub cur: usize,
129 pub attrs: BTreeMap<String, String>,
131 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; return true;
442 }
443 }
444
445 return false; }
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#[derive(Debug, Clone, PartialEq, Eq)]
507pub enum MatchOutcome {
508 Message(PlumbingMessage),
510 Run(String),
512}
513
514#[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 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 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 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}"); }
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}