Skip to main content

lowfat_runner/
lf_filter.rs

1use anyhow::{Context, Result};
2use lowfat_core::lf::{self, ExecCtx};
3use lowfat_plugin::plugin::{FilterInput, FilterOutput, FilterPlugin, PluginInfo};
4use std::path::PathBuf;
5
6/// Runs a `.lf` plugin in-process by executing the parsed [`lf::RuleSet`].
7/// Shell and Python escape hatches still spawn subprocesses, but built-in
8/// ops (keep/drop/head/tail/else) run without forking.
9pub struct LfFilter {
10    pub info: PluginInfo,
11    pub ruleset: lf::RuleSet,
12    pub entry: PathBuf,
13}
14
15impl LfFilter {
16    pub fn load(info: PluginInfo, entry: PathBuf) -> Result<Self> {
17        let source = std::fs::read_to_string(&entry)
18            .with_context(|| format!("reading {}", entry.display()))?;
19        let ruleset =
20            lf::parse(&source).with_context(|| format!("parsing {}", entry.display()))?;
21        Ok(Self {
22            info,
23            ruleset,
24            entry,
25        })
26    }
27}
28
29impl FilterPlugin for LfFilter {
30    fn info(&self) -> PluginInfo {
31        self.info.clone()
32    }
33
34    fn filter(&self, input: &FilterInput) -> Result<FilterOutput> {
35        let ctx = ExecCtx {
36            sub: &input.subcommand,
37            level: input.level,
38            exit_code: input.exit_code,
39            args: &input.args,
40        };
41        // On execution error, degrade to passthrough — never make output
42        // worse than no filter at all.
43        match lf::execute(&self.ruleset, &ctx, &input.raw) {
44            Ok(text) => Ok(FilterOutput {
45                passthrough: text.is_empty(),
46                text,
47            }),
48            Err(e) => {
49                eprintln!("[lowfat] {} filter error: {e:#}", self.info.name);
50                Ok(FilterOutput {
51                    passthrough: true,
52                    text: input.raw.clone(),
53                })
54            }
55        }
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use lowfat_core::level::Level;
63    use std::io::Write;
64
65    fn make_input(raw: &str, sub: &str, level: Level) -> FilterInput {
66        FilterInput {
67            raw: raw.to_string(),
68            command: "test".into(),
69            subcommand: sub.into(),
70            args: vec![],
71            level,
72            head_limit: 30,
73            exit_code: 0,
74        }
75    }
76
77    fn write_lf(name: &str, body: &str) -> PathBuf {
78        let dir = std::env::temp_dir().join(format!(
79            "lowfat-lf-test-{name}-{}",
80            std::process::id()
81        ));
82        let _ = std::fs::remove_dir_all(&dir);
83        std::fs::create_dir_all(&dir).unwrap();
84        let path = dir.join("filter.lf");
85        let mut f = std::fs::File::create(&path).unwrap();
86        f.write_all(body.as_bytes()).unwrap();
87        path
88    }
89
90    fn info() -> PluginInfo {
91        PluginInfo {
92            name: "test".into(),
93            version: "0.0.0".into(),
94            commands: vec!["test".into()],
95            subcommands: vec![],
96        }
97    }
98
99    #[test]
100    fn lf_filter_runs_keep_head() {
101        let path = write_lf(
102            "kh",
103            r#"
104status:
105    keep /^M /
106    head 2
107"#,
108        );
109        let f = LfFilter::load(info(), path).unwrap();
110        let out = f
111            .filter(&make_input(
112                "M one\n?? two\nM three\nM four\nM five\n",
113                "status",
114                Level::Full,
115            ))
116            .unwrap();
117        assert_eq!(out.text, "M one\nM three\n");
118    }
119
120    #[test]
121    fn lf_filter_passthrough_on_parse_error_falls_back() {
122        // Write a deliberately broken .lf file
123        let path = write_lf("bad", "this is not valid syntax @!#\n");
124        let res = LfFilter::load(info(), path);
125        assert!(res.is_err(), "expected parse error");
126    }
127
128    #[test]
129    fn lf_filter_no_match_passes_through() {
130        let path = write_lf(
131            "nm",
132            r#"
133specific:
134    head 1
135"#,
136        );
137        let f = LfFilter::load(info(), path).unwrap();
138        let out = f
139            .filter(&make_input("a\nb\nc\n", "other", Level::Full))
140            .unwrap();
141        assert_eq!(out.text, "a\nb\nc\n");
142    }
143}