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    /// Build from an in-memory `.lf` source — used by embedded plugins where
29    /// the source string lives in `.rodata` and never touches disk. `entry`
30    /// is a synthetic display-only path for error messages.
31    pub fn from_source(info: PluginInfo, source: &str, entry: PathBuf) -> Result<Self> {
32        let ruleset =
33            lf::parse(source).with_context(|| format!("parsing {}", entry.display()))?;
34        Ok(Self {
35            info,
36            ruleset,
37            entry,
38        })
39    }
40}
41
42impl FilterPlugin for LfFilter {
43    fn info(&self) -> PluginInfo {
44        self.info.clone()
45    }
46
47    fn filter(&self, input: &FilterInput) -> Result<FilterOutput> {
48        let ctx = ExecCtx {
49            sub: &input.subcommand,
50            level: input.level,
51            exit_code: input.exit_code,
52            args: &input.args,
53        };
54        // On execution error, degrade to passthrough — never make output
55        // worse than no filter at all.
56        match lf::execute(&self.ruleset, &ctx, &input.raw) {
57            Ok(text) => Ok(FilterOutput {
58                passthrough: text.is_empty(),
59                text,
60            }),
61            Err(e) => {
62                eprintln!("[lowfat] {} filter error: {e:#}", self.info.name);
63                Ok(FilterOutput {
64                    passthrough: true,
65                    text: input.raw.clone(),
66                })
67            }
68        }
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use lowfat_core::level::Level;
76    use std::io::Write;
77
78    fn make_input(raw: &str, sub: &str, level: Level) -> FilterInput {
79        FilterInput {
80            raw: raw.to_string(),
81            command: "test".into(),
82            subcommand: sub.into(),
83            args: vec![],
84            level,
85            head_limit: 30,
86            exit_code: 0,
87        }
88    }
89
90    fn write_lf(name: &str, body: &str) -> PathBuf {
91        let dir = std::env::temp_dir().join(format!(
92            "lowfat-lf-test-{name}-{}",
93            std::process::id()
94        ));
95        let _ = std::fs::remove_dir_all(&dir);
96        std::fs::create_dir_all(&dir).unwrap();
97        let path = dir.join("filter.lf");
98        let mut f = std::fs::File::create(&path).unwrap();
99        f.write_all(body.as_bytes()).unwrap();
100        path
101    }
102
103    fn info() -> PluginInfo {
104        PluginInfo {
105            name: "test".into(),
106            version: "0.0.0".into(),
107            commands: vec!["test".into()],
108            subcommands: vec![],
109        }
110    }
111
112    #[test]
113    fn lf_filter_runs_keep_head() {
114        let path = write_lf(
115            "kh",
116            r#"
117status:
118    keep /^M /
119    head 2
120"#,
121        );
122        let f = LfFilter::load(info(), path).unwrap();
123        let out = f
124            .filter(&make_input(
125                "M one\n?? two\nM three\nM four\nM five\n",
126                "status",
127                Level::Full,
128            ))
129            .unwrap();
130        assert_eq!(out.text, "M one\nM three\n");
131    }
132
133    #[test]
134    fn lf_filter_passthrough_on_parse_error_falls_back() {
135        // Write a deliberately broken .lf file
136        let path = write_lf("bad", "this is not valid syntax @!#\n");
137        let res = LfFilter::load(info(), path);
138        assert!(res.is_err(), "expected parse error");
139    }
140
141    #[test]
142    fn lf_filter_no_match_passes_through() {
143        let path = write_lf(
144            "nm",
145            r#"
146specific:
147    head 1
148"#,
149        );
150        let f = LfFilter::load(info(), path).unwrap();
151        let out = f
152            .filter(&make_input("a\nb\nc\n", "other", Level::Full))
153            .unwrap();
154        assert_eq!(out.text, "a\nb\nc\n");
155    }
156}