Skip to main content

lowfat_runner/
runner.rs

1use anyhow::Result;
2use lowfat_core::pipeline::{apply_builtin, proc_normalize, Pipeline, StageType};
3use lowfat_plugin::discovery::DiscoveredPlugin;
4use lowfat_plugin::plugin::{FilterInput, FilterPlugin, PluginInfo};
5use lowfat_plugin::security;
6use std::collections::HashMap;
7
8use crate::lf_filter::LfFilter;
9use crate::process::ProcessFilter;
10
11/// Loads a discovered plugin into a runnable [`FilterPlugin`]. Dispatches
12/// on the entry's file extension: `.lf` → in-process [`LfFilter`], any
13/// other extension → external [`ProcessFilter`] via `sh`.
14///
15/// The entrypoint is resolved by `RuntimeConfig::resolve_entry`: an explicit
16/// `runtime.entry` in the manifest wins; otherwise it is auto-detected —
17/// `filter.lf` when present, else `filter.sh`. A plain `.lf` plugin therefore
18/// needs no `[runtime]` table at all.
19pub struct HybridRunner;
20
21impl HybridRunner {
22    pub fn load(plugin: &DiscoveredPlugin) -> Result<Box<dyn FilterPlugin>> {
23        let manifest = &plugin.manifest;
24        let entry_path = plugin
25            .base_dir
26            .join(manifest.runtime.resolve_entry(&plugin.base_dir));
27
28        let info = PluginInfo {
29            name: manifest.plugin.name.clone(),
30            version: manifest
31                .plugin
32                .version
33                .clone()
34                .unwrap_or_else(|| "0.0.0".to_string()),
35            commands: manifest.plugin.commands.clone(),
36            subcommands: manifest
37                .plugin
38                .subcommands
39                .clone()
40                .unwrap_or_default(),
41        };
42
43        // Security validation
44        if let Err(e) = security::validate_plugin(manifest, &plugin.base_dir) {
45            anyhow::bail!("security check failed for '{}': {e}", manifest.plugin.name);
46        }
47
48        let is_lf = entry_path
49            .extension()
50            .map(|e| e == "lf")
51            .unwrap_or(false);
52        if is_lf {
53            let filter = LfFilter::load(info, entry_path)?;
54            Ok(Box::new(filter))
55        } else {
56            let filter = ProcessFilter {
57                info,
58                entry: entry_path,
59                base_dir: plugin.base_dir.clone(),
60            };
61            Ok(Box::new(filter))
62        }
63    }
64}
65
66/// Execute a pipeline chain against raw command output.
67/// Chains built-in processors and plugin filters in order.
68///
69/// For built-in stages: runs in-process (zero overhead).
70/// For plugin stages: looks up the plugin by name and delegates.
71pub fn execute_pipeline(
72    pipeline: &Pipeline,
73    raw: &str,
74    input_template: &FilterInput,
75    plugin_map: &HashMap<String, Box<dyn FilterPlugin>>,
76) -> Result<String> {
77    let mut text = raw.to_string();
78
79    for stage in &pipeline.stages {
80        // Plugin override: if a plugin exists with the same name as a builtin, plugin wins.
81        // This lets users replace any built-in processor with their own implementation.
82        if let Some(filter) = plugin_map.get(&stage.name) {
83            let mut stage_input = input_template.clone();
84            stage_input.raw = text.clone();
85            match filter.filter(&stage_input) {
86                Ok(out) if !out.passthrough => {
87                    text = out.text;
88                }
89                Ok(_) => {}
90                Err(_) => {}
91            }
92            continue;
93        }
94
95        // Fall back to built-in processor
96        if stage.stage_type == StageType::Builtin {
97            if let Some(processed) = apply_builtin(&stage.name, &text, input_template.level, stage.param, stage.pattern.as_deref()) {
98                text = processed;
99            }
100        }
101        // Unknown plugin not in map → skip (passthrough)
102    }
103
104    // Final cleanup: trim trailing whitespace, collapse blank lines
105    Ok(proc_normalize(&text))
106}
107
108/// Execute a command and capture its output.
109pub fn exec_command(cmd: &str, args: &[String]) -> Result<(String, i32)> {
110    let output = std::process::Command::new(cmd)
111        .args(args)
112        .output()?;
113
114    let exit_code = output.status.code().unwrap_or(1);
115    let mut combined = String::from_utf8_lossy(&output.stdout).to_string();
116    let stderr = String::from_utf8_lossy(&output.stderr);
117    if !stderr.is_empty() {
118        if !combined.is_empty() {
119            combined.push('\n');
120        }
121        combined.push_str(&stderr);
122    }
123
124    Ok((combined, exit_code))
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use lowfat_core::level::Level;
131    use lowfat_core::pipeline::Pipeline;
132
133    fn make_input(raw: &str) -> FilterInput {
134        FilterInput {
135            raw: raw.to_string(),
136            command: "test".to_string(),
137            subcommand: String::new(),
138            args: vec![],
139            level: Level::Full,
140            head_limit: 40,
141            exit_code: 0,
142        }
143    }
144
145    #[test]
146    fn execute_builtin_only_pipeline() {
147        let pipeline = Pipeline::parse("strip-ansi | dedup-blank");
148        let raw = "\x1b[31mERROR\x1b[0m\n\n\n\nline2";
149        let input = make_input(raw);
150        let result = execute_pipeline(&pipeline, raw, &input, &HashMap::new()).unwrap();
151        assert_eq!(result, "ERROR\n\nline2\n");  // normalize collapses blanks + trims
152    }
153
154    #[test]
155    fn execute_passthrough_pipeline() {
156        let pipeline = Pipeline::parse("passthrough");
157        let raw = "hello world";
158        let input = make_input(raw);
159        let result = execute_pipeline(&pipeline, raw, &input, &HashMap::new()).unwrap();
160        assert_eq!(result, "hello world\n");  // normalize ensures trailing newline
161    }
162
163    #[test]
164    fn execute_truncate_pipeline() {
165        let pipeline = Pipeline::parse("head");
166        let raw = (0..100).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n");
167        let input = make_input(&raw);
168        let result = execute_pipeline(&pipeline, &raw, &input, &HashMap::new()).unwrap();
169        // Full level head limit for base 40 = 40 lines
170        let line_count = result.lines().count();
171        assert!(line_count <= 41); // 40 lines + truncation message
172    }
173
174    #[test]
175    fn execute_chain_strip_then_truncate() {
176        let pipeline = Pipeline::parse("strip-ansi | head");
177        let mut raw = String::new();
178        for i in 0..100 {
179            raw.push_str(&format!("\x1b[32mline{i}\x1b[0m\n"));
180        }
181        let input = make_input(&raw);
182        let result = execute_pipeline(&pipeline, &raw, &input, &HashMap::new()).unwrap();
183        // Should be ANSI-stripped AND truncated
184        assert!(!result.contains("\x1b["));
185        assert!(result.lines().count() <= 41);
186    }
187
188    #[test]
189    fn missing_plugin_skipped() {
190        let pipeline = Pipeline::parse("strip-ansi | nonexistent-plugin | head");
191        let raw = "\x1b[31mhello\x1b[0m\nworld";
192        let input = make_input(raw);
193        // nonexistent-plugin is StageType::Plugin, not in map → skipped
194        let result = execute_pipeline(&pipeline, raw, &input, &HashMap::new()).unwrap();
195        assert!(result.contains("hello"));
196        assert!(!result.contains("\x1b["));
197    }
198}